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.
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.
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');
};
{
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.
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.
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.
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
Secure an app with a real Keycloak instance
Run Keycloak locally (Section 6) and protect a small app end-to-end.
Requirements:
- Bring up Keycloak via Docker Compose, create a realm with a public client (PKCE) and at least two users — one with an
adminrealm role, one without. - Wire up
provideKeycloak()(Section 2) withonLoad: 'check-sso', and confirm a public route renders without forcing a login. - Add an
/adminroute protected byrequireRole('admin')(Section 3) — confirm the non-admin user lands on a forbidden page, not a broken navigation. - Add the bearer-token interceptor (Section 4), scoped to your own mock API's URL pattern, and confirm via dev tools that the
Authorizationheader is present on those requests and absent on requests to any other origin. - 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).
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?
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?
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?
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?
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.