Week 30: Identity & Access Management with Keycloak

Week 23 covered OAuth2/OIDC concepts and a mocked JWT flow. This week plugs in a real identity provider — Keycloak — and deals with everything that mocked version deliberately skipped: bootstrapping before the app renders, silent refresh, and role-based access that actually comes from a token, not a hardcoded flag.

Phase 7 of 8 Week 30 of 33 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Bootstrap Keycloak before the app renders so routing and guards can rely on auth state
  • Write functional route guards that check real Keycloak roles, and an interceptor that attaches the token
  • Handle silent token refresh and session expiry without dropping the user mid-action

1. Realms, Clients & Roles

Week 23 covered OIDC in the abstract — an authorization server issuing tokens after login. Keycloak is a concrete, self-hostable implementation of that server, organized around three concepts you'll configure directly.

  • Realm — an isolated space of users, roles and clients. Most orgs run one realm per product or per environment (dev/staging/prod), not one per team.
  • Client — an application registered to use the realm. Your Angular app is a public client (no client secret — it can't keep one safe in browser-shipped code), configured with the PKCE flow rather than the implicit flow Keycloak's older docs sometimes still show.
  • Roles — realm-level or client-level permissions (e.g. admin, editor) assigned to users, embedded directly in the access token's claims once issued.

That last point is what makes Section 3's route guards possible: roles aren't a separate API call away, they're already sitting in the token the app holds after login.

2. Bootstrapping Before the App Renders

Routing decisions (which route a guard allows) can depend on auth state that isn't known until Keycloak finishes its own initialization — checking for an existing session, handling a redirect back from the login page. That has to complete before Angular's router makes its first decision, which means it belongs in app initialization, not a component's constructor.

app.config.ts
import { ApplicationConfig, provideAppInitializer, inject } from '@angular/core';
import Keycloak from 'keycloak-js';
import { KEYCLOAK_EVENT_SIGNAL, provideKeycloak, createInterceptorCondition } from 'keycloak-angular';

export const appConfig: ApplicationConfig = {
  providers: [
    provideKeycloak({
      config: {
        url: 'https://auth.example.com',
        realm: 'my-app',
        clientId: 'my-app-web',
      },
      initOptions: {
        onLoad: 'check-sso', // don't force login on every route --
                              // only routes behind a guard require it
        silentCheckSsoRedirectUri: window.location.origin + '/silent-check-sso.html',
      },
    }),
    // ...provideRouter, provideHttpClient, etc.
  ],
};

provideKeycloak() (from keycloak-angular) wires Keycloak's own initialization into Angular's bootstrap sequence via provideAppInitializer internally — the router won't activate its first route until Keycloak has finished checking for an existing session. onLoad: 'check-sso' is deliberate: it checks silently without forcing a login redirect for public routes, leaving the actual "require login" decision to the route guards in Section 3.

3. Role-Based Route Guards

A functional guard (Week 11) reading Keycloak's own state is a natural fit — no custom auth service needed for the common case.

admin.guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { Keycloak } from 'keycloak-angular';

export const requireRole = (role: string): CanActivateFn => () => {
  const keycloak = inject(Keycloak);
  const router = inject(Router);

  if (!keycloak.authenticated) {
    keycloak.login({ redirectUri: window.location.href });
    return false;
  }

  const hasRole = keycloak.hasRealmRole(role);
  return hasRole ? true : router.parseUrl('/forbidden');
};
app.routes.ts
{
  path: 'admin',
  loadChildren: () => import('./admin/admin.routes'),
  canActivate: [requireRole('admin')],
},

Returning router.parseUrl(...) instead of false (Week 11) gives an authenticated-but-unauthorized user a real "forbidden" page rather than a route that silently fails to navigate — a materially better experience for someone who's logged in correctly but simply lacks the role.

4. Attaching the Token

keycloak-angular ships a functional interceptor factory built on the same condition-matching pattern Week 14 used for scoping interceptors to specific requests.

app.config.ts — wiring the interceptor
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { includeBearerTokenInterceptor, createInterceptorCondition, INCLUDE_BEARER_TOKEN_INTERCEPTOR_CONFIG } from 'keycloak-angular';

const bearerCondition = createInterceptorCondition({
  urlPattern: /^https:\/\/api\.example\.com\/.*$/i,
});

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(withInterceptors([includeBearerTokenInterceptor])),
    { provide: INCLUDE_BEARER_TOKEN_INTERCEPTOR_CONFIG, useValue: [bearerCondition] },
    // ...provideKeycloak from Section 2
  ],
};

Scoping the condition to your own API's URL pattern matters for the same reason Week 14 flagged for any auth interceptor — attaching your access token to requests going to a third-party domain (an analytics SDK, a payment provider) would leak it somewhere it has no business going.

5. Silent Refresh & Session Expiry

Access tokens are deliberately short-lived (often 5 minutes). Without refresh handling, a user mid-session hits a wall of 401s the moment it expires — Keycloak's refresh token exists specifically to renew it invisibly.

reacting to Keycloak's own event signal
import { effect, inject } from '@angular/core';
import { KEYCLOAK_EVENT_SIGNAL, KeycloakEventType, typeEventArgs } from 'keycloak-angular';

const keycloakSignal = inject(KEYCLOAK_EVENT_SIGNAL);

effect(() => {
  const event = keycloakSignal();

  if (event.type === KeycloakEventType.TokenExpired) {
    // keycloak-angular attempts a silent refresh automatically here in
    // most configurations; this is where you'd add app-specific handling
  }

  if (event.type === KeycloakEventType.AuthLogout) {
    router.navigate(['/login']);
  }
});

KEYCLOAK_EVENT_SIGNAL is itself a signal — reacting to auth lifecycle events is exactly the same effect() pattern used everywhere else in the course, not a separate event-subscription API to learn. If a refresh genuinely fails (the refresh token itself expired — a real session timeout, not a routine renewal), route the user to login with their current location preserved, so they land back where they were rather than at a generic home page.

6. Running Keycloak Locally

Developing against a shared staging Keycloak instance is slow and makes test users a shared, fragile resource — a local instance with an exported realm config is worth the one-time setup cost.

docker-compose.yml
services:
  keycloak:
    image: quay.io/keycloak/keycloak:latest
    command: start-dev --import-realm
    environment:
      KEYCLOAK_ADMIN: admin
      KEYCLOAK_ADMIN_PASSWORD: admin
    ports:
      - '8080:8080'
    volumes:
      - ./keycloak/my-app-realm.json:/opt/keycloak/data/import/my-app-realm.json

Export your realm's configuration (users, clients, roles) once from the admin console into my-app-realm.json and commit it — every teammate then gets an identical realm, with the same test users and roles, by running docker compose up, instead of manually recreating auth configuration by hand.

7. Hands-on Exercise

Hands-on

Secure an app with a real Keycloak instance

Run Keycloak locally (Section 6) and protect a small app end-to-end.

Requirements:

  1. Bring up Keycloak via Docker Compose, create a realm with a public client (PKCE) and at least two users — one with an admin realm role, one without.
  2. Wire up provideKeycloak() (Section 2) with onLoad: 'check-sso', and confirm a public route renders without forcing a login.
  3. Add an /admin route protected by requireRole('admin') (Section 3) — confirm the non-admin user lands on a forbidden page, not a broken navigation.
  4. Add the bearer-token interceptor (Section 4), scoped to your own mock API's URL pattern, and confirm via dev tools that the Authorization header is present on those requests and absent on requests to any other origin.
  5. Force a token expiry (Keycloak's admin console lets you shorten access token lifetime for testing) and confirm the app either silently refreshes or, on genuine session expiry, redirects to login with the pre-expiry location preserved (Section 5).
Hint

If check-sso silently fails or hangs, it's almost always the silentCheckSsoRedirectUri — that file needs to exist as a genuinely empty static HTML page at the exact path configured, served by your dev server, so Keycloak's hidden iframe check can complete without loading your full app inside it.

8. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why is the Angular app registered as a "public" Keycloak client rather than a confidential one?

A confidential client requires a client secret, and any secret shipped in browser-delivered JavaScript is visible to anyone who opens dev tools — it can't actually be kept secret. Public clients use the PKCE flow instead, which is designed specifically for clients that can't securely hold a secret.

Q2

Why does Keycloak's initialization need to complete before the router makes its first routing decision?

Route guards need to know whether a user is already authenticated (from an existing session) to decide whether to allow a protected route — if the router runs before that check finishes, a guard could incorrectly treat an actually-logged-in user as unauthenticated, or vice versa, based on incomplete state.

Q3

Why does the bearer-token interceptor need to be scoped to a URL pattern instead of attaching the token to every outgoing request?

An app typically also calls third-party services (analytics, payment providers, CDNs) — attaching your own API's access token to those unrelated requests would leak a credential to a domain that has no business receiving it. Scoping the condition to your own API's URL pattern keeps the token confined to where it belongs.

Q4

What's the practical difference between an access token expiring and a refresh token expiring, from the user's perspective?

An expired access token with a still-valid refresh token is invisible to the user — the app silently obtains a new access token and continues. An expired refresh token means the session itself is genuinely over; the user needs to log in again, ideally landing back where they were rather than at a generic home page.