@centeva/msal-angular
v1.1.0
Published
Angular MSAL authentication helpers for Centeva applications
Readme
@centeva/msal-angular
Angular MSAL authentication for Centeva applications, in one provider call.
The point of this package is not to save you a config object. It is to guarantee that your application performs at most one interactive login per page lifetime, no matter how many API calls fire at startup or where they fire from. Getting that wrong is subtle, silent, and present in every Angular app that wires MSAL the obvious way — see One login, then fan out.
Status. Implemented, unit-tested, mutation-tested, and verified end to end against a real Keycloak: one interactive login, with every startup API call carrying a token. ADR 0001 is still
Pendingreview, and nothing has been published yet.
Installation
npm install @centeva/msal-angularInstall peer dependencies if not already present:
npm install @azure/msal-angular @azure/msal-browserRequires Angular 22, and @azure/msal-browser 5.11 or newer. The version floor is not
arbitrary — the gate depends on handleRedirectPromise memoisation and synchronous event
emission, both of which landed in 5.11.
One login, then fan out
Wire MSAL the documented way and this happens:
- Your app starts and fires several API calls at once — a guard, a resolver, a root
component's
ngOnInit, a service constructor. - Each one hits
MsalInterceptor. EachacquireTokenSilentfails, because there is no token yet. - Each one checks whether an interaction is in progress, sees
None, and starts its own redirect. There is no lock between the check and the start. - The losers throw
interaction_in_progress. Their error cleanup deletes the winner's PKCE verifier and clears the interaction flag. - The winner still reaches the identity provider and still returns with a valid authorization code — which is discarded, because the flag it needs is gone.
- Your user logs in again. No error is reported.
If you have seen an application ask for credentials twice on a cold start, this is very likely
why. The tell is an unhandled BrowserAuthError: interaction_in_progress before the redirect,
and MSAL verbose log code 0le6uv after it.
This package prevents it in two pieces, because one is not enough:
- A single-flight token acquisition, keyed on the scope set, so concurrent callers share one request. It re-arms when the request settles, so an access token expiring mid-session is handled exactly like a cold start rather than falling through an unprotected path.
- A one-way latch around interactive calls, set before the call starts. A redirect means the page is unloading; a second interactive call is never legitimate.
A latch alone would block legitimate re-acquisition after expiry. A single-flight alone would allow a second interaction once the first settled. Both, together.
You do not configure any of this. It is what provideOidcAuth() sets up.
Ordering matters if you rewrite URLs. The protected-resource map is matched against the URL as it exists when the interceptor runs. If your app has an interceptor that rewrites relative API paths to absolute ones, register it after
provideOidcAuth, or matching silently stops and no tokens are attached. A development-mode warning is emitted if no request ever matches.
Setup
1. Register providers
The config factory is called lazily at DI resolution time, so it can safely read from a config service populated by an app initializer:
import { bootstrapApplication } from '@angular/platform-browser';
import { inject, provideAppInitializer } from '@angular/core';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { provideOidcAuth } from '@centeva/msal-angular';
import { AppComponent } from './app.component';
import { ConfigService } from './config.service';
bootstrapApplication(AppComponent, {
providers: [
provideAppInitializer(() => inject(ConfigService).load()),
// Required — without this the MSAL HTTP interceptor is never registered
provideHttpClient(withInterceptorsFromDi()),
provideOidcAuth(() => inject(ConfigService).authConfig),
],
});For static configuration:
provideOidcAuth(() => ({
clientId: 'my-client',
authority: 'https://auth.example.com/realms/my-realm',
redirectUri: 'http://localhost:4200/auth',
scopes: ['openid', 'profile', 'email', 'all'],
apiBaseUrl: 'myapp/api',
}))2. Handle the login redirect
MSAL needs somewhere to process the response it gets back from the identity provider.
import { MsalRedirectComponent } from '@azure/msal-angular';
const routes: Routes = [
{ path: 'auth', component: MsalRedirectComponent },
// ...
];Make
redirectUriagree with your routing strategy. This is a trap worth stating plainly, because more than one Centeva application has fallen into it: if you use hash routing (withHashLocation()) and setredirectUrito a path likehttps://app/auth, theauthroute can never match — hash routing only seeshttps://app/#/auth. The redirect component never activates, and redirect handling silently falls through to whatever else happens to callhandleRedirectObservable(). It usually still works, which is what makes it hard to spot.Either use a path-shaped
redirectUriwith path routing, or a hash-shaped one with hash routing. Do not mix them.
You do not need to call handleRedirectObservable() yourself in AppComponent. The package
owns that call so it can sequence it correctly against token acquisition. Calling it again is
harmless — MSAL memoises it — but it is redundant.
3. Guard your routes
import { oidcAuthGuard } from '@centeva/msal-angular';
{
path: '',
canActivate: [oidcAuthGuard],
component: ShellComponent,
}oidcAuthGuard waits for authentication and for your bootstrap call (below) before the route
activates, so everything downstream runs with a warm token cache.
Guards in a
canActivatearray run concurrently, not in sequence — Angular subscribes to all of them at once. Do not rely on ordering within the array for correctness.
The bootstrap call
Most Centeva applications need to fetch the current user from their own backend before anything
else runs. Provide OIDC_BOOTSTRAP_FN and the guard will await it:
import { OIDC_BOOTSTRAP_FN } from '@centeva/msal-angular';
{
provide: OIDC_BOOTSTRAP_FN,
useFactory: () => {
const http = inject(HttpClient);
return () => http.get<User>('api/Authorization/GetAuthorizedUser');
},
}It runs exactly once, after a token is available and before the route activates. It is optional — applications without such an endpoint still get the single-login guarantee, since the gate primes a token regardless.
The package defines when the call happens. You define what it is — the endpoint and the user model stay yours.
OidcAuthConfig options
| Option | Required | Default | Description |
|---|---|---|---|
| clientId | ✅ | — | OIDC client ID |
| authority | ✅ | — | OIDC authority URL |
| redirectUri | ✅ | — | Post-login redirect URI |
| postLogoutRedirectUri | | MSAL's default (the current page) | Post-logout redirect URI |
| knownAuthorities | | [authority] | Trusted authority hosts. Required for non-Entra providers. |
| scopes | ✅ | — | OAuth 2.0 scopes (e.g. ['openid', 'profile', 'email', 'all']) |
| protocolMode | | 'OIDC' | 'OIDC' for Keycloak and other standards-conforming providers; 'AAD' for Entra ID |
| responseMode | | MSAL's default ('fragment') | 'query' if your application uses hash routing — see below |
| apiBaseUrl | | — | API base path — tokens are attached to ${apiBaseUrl}/* requests |
| loginFailedRoute | | '/unauthorized' | Route navigated to on login failure |
responseMode follows your routing strategy
The authorization response has to arrive wherever the Angular Router is not looking.
| Routing | Router owns | Set responseMode |
|---|---|---|
| Path (Angular's default) | the query string | leave unset — fragment |
| Hash (withHashLocation()) | the fragment | 'query' |
Neither value is right for both, which is why this is a consumer decision rather than something the package can fix once. Both failure modes are measured in the demo's end-to-end suite, and neither reports its own cause:
- Hash routing with a fragment response — the Router reads the response as a route, matches
nothing, and sign-in dies with
NG04002before MSAL is asked to process anything. An application with a catch-all**route hides this: the response is swallowed as a misroute and sign-in then completes, which makes it look application-specific. - Path routing with a query response — sign-in loops between the provider and the redirect route without ever redeeming the code. There is no error in the console.
Signing out
Inject OidcSignOutService:
private readonly signOutService = inject(OidcSignOutService);
signOut() {
this.signOutService.signOut();
}Do not call MsalService.logoutRedirect() directly. It does not reliably end the session,
and every way it fails looks like success:
- MSAL forwards
id_token_hintonly when the caller passes it. It never derives it from the account, and never sendsclient_idon logout either. Given neither, the provider cannot tell which session is ending — Keycloak answers with a confirmation page, and a user who closes the tab rather than clicking it stays signed in. The application looks signed out because its own cache was cleared first. - Setting
postLogoutRedirectUriwithoutid_token_hintis worse: with no session to validate the redirect target against, Keycloak declines to end the session at all and returns the user to the application still authenticated.
OidcSignOutService supplies the account's raw ID token as id_token_hint, which resolves both,
and passes the account so MSAL can derive logout_hint itself. Pass an EndSessionRequest to
override any of it.
Do not set logoutHint yourself. MSAL derives it from account.loginHint or the account's
login_hint ID-token claim, but only when the caller leaves it unset — supplying any value takes
the manual branch and overrides that. Entra matches logout_hint against the login_hint claim
specifically, so a plausible-looking username is silently ignored and the account picker appears
anyway. Note login_hint is an optional claim: to suppress the picker on Entra, add it to the
ID token on the client's app registration.
If every route in your application is guarded, leave postLogoutRedirectUri unset or null.
Returning to a guarded route after sign-out starts a new interactive sign-in while MSAL is still
clearing its cache from the logout: the new request writes its state, the tail of the logout wipe
removes it, and the response fails with state_mismatch. Observed against Entra ID; Keycloak
happens to finish cleaning up before the new request begins, so it hides the race rather than
avoiding it. With no value set, the provider shows its own signed-out page and nothing returns to
the application to trigger it.
Only set it when you have somewhere unguarded to land, and register that URI with the provider.
What this package decides, and what it doesn't
It answers "are you authenticated?" — sign-in, token acquisition, and the ordering guarantee above.
It does not answer "may you do this operation?" That is your application's, using its own user model and route data. The package deliberately offers no role guard: no two Centeva applications agree on a role model, so any abstraction here would be indirection over a function you have to supply anyway.
This mirrors the boundary Centeva.Oidc draws on the
server, where the API policy decides whether you may call the API at all and per-endpoint
[Authorize(Policy = "...")] decides what you may do once you are in. App-only and
client-credentials callers are a server-side concern with no frontend analogue.
Also out of scope, deliberately: URL rewriting, state management, the current-user model, and your unauthorized page.
Pairing with Centeva.Oidc
This package is the frontend counterpart to
Centeva.Oidc. Four things have to line up.
Both sides take the same authority. Configuration is plain OIDC — every endpoint and
signing key is discovered from the authority's .well-known/openid-configuration. Deriving it
two different ways in two places is how the two halves drift apart.
Your clientId here is not the server's ClientId. The server's is for the login it
performs on its own behalf, for the Hangfire dashboard and API docs routes. Under the usual
two-registration setup, your frontend uses the client registration and the server never sees it.
See the server's Audience and ClientId section.
Your scopes must satisfy the server's claim requirements. Include openid profile email
so the four required delegated claims — sub, given_name, email, family_name — are
present, plus whatever the server's RequiredScope is set to (it defaults to all). A token
missing any of them gets a 403 that is deliberately opaque; the server logs which claim was
missing.
Your bootstrap config must be reachable before you authenticate. The server applies its API policy as the authorization fallback by default, which protects every endpoint. Your SPA's entry document and any configuration it fetches during startup are requested by a browser that has not authenticated yet, so they must opt out:
app.MapFallbackToFile("index.html").AllowAnonymous();
app.MapGet("/config", ...).AllowAnonymous();Miss these and the application does not load at all — there is no tidy 401 pointing at the
cause. See the server's Public routes must opt out section.
Configuration loading
provideOidcAuth() takes a factory, so it does not care where your configuration comes from —
a runtime JSON fetch, environment files, or a literal. The pattern used across Centeva
applications is a ConfigService that fetches assets/config/app-config.json through a raw
HttpBackend, deliberately bypassing every interceptor, driven by an app initializer so the
values are present before MSAL is constructed.
That pattern is not shipped here yet. It is worth revisiting once more than one application has adopted this package.
Running with authentication handled elsewhere
Transitional.
provideNoAuth()andprovideAuth()exist to carry an application through its migration from Windows authentication to OIDC. They are removed once every consuming application has converted — a breaking change, so a major version. An application with no Windows fallback should callprovideOidcAuth()directly and ignore this section.
An application still on Windows authentication cannot simply omit provideOidcAuth().
OidcReadyService, OidcTokenGate and OidcSignOutService are providedIn: 'root', so the
first injection of any of them constructs MsalService and fails on the missing MSAL_INSTANCE —
and OidcGateInterceptor is constructed as soon as Angular resolves HTTP_INTERCEPTORS, which
is the first HTTP request of any kind. The failure lands mid-session rather than at bootstrap.
provideNoAuth() overrides those three with inert implementations and registers neither
interceptor, so no PublicClientApplication is ever constructed. Guards still resolve,
OidcSignOutService is still injectable, and your route files, components and specs are
identical in both modes:
import { provideAuth } from '@centeva/msal-angular';
fetch('assets/config/app-config.json')
.then(response => response.json())
.then((config: AppConfig) =>
bootstrapApplication(AppComponent, {
providers: [
provideHttpClient(withInterceptorsFromDi()),
provideAuth(config.authMode, () => config.authConfig),
],
}),
);The mode must be known before bootstrapApplication, because the providers array is built
synchronously — hence the fetch ahead of bootstrap rather than an app initializer. The config
factory is not called at all in windows mode, so it may read values that exist only on the
OIDC path.
Three things worth knowing:
oidcAuthGuardreturnstruein this mode. It has no token to wait for, so authorization is entirely the server's. Your own role or permission guards are unaffected and still run.- Stale MSAL state is cleared at bootstrap. This package caches in
localStorage, which survives a deployment, so switching an application out of OIDC would otherwise leave tokens in every existing user's browser. Only MSAL's own keys are removed. OIDC_BOOTSTRAP_FNstill runs, exactly once, so startup sequencing is the same in both modes — only the wait for a token in front of it disappears.
This switches this package only. The server's authentication scheme and your application's
identity resolution are separate concerns, and all three have to agree. See
ADR 0004
in Centeva.Oidc for the server half and the removal checklist.
Contributing
npm install
npm run typecheck
npm test # unit tests (Vitest)
npm run test:coverage # the same tests, with coverage floors enforced
npm run build # ng-packagr -> dist/CI runs test:coverage, so a drop below the floors in
vitest.config.ts fails the build. Treat them as a ratchet against
untested branches accumulating — not as evidence the package works, which is what the two
checks below are for.
Decisions are recorded in docs/adr. Read ADR 0001 before changing anything that acquires a token — in particular its rejected alternatives, which document two designs that look correct and are not.
Verifying a change
The unit tests are not sufficient on their own, and this is not a matter of coverage. They
construct services with new, so they never exercise Angular's injector; the Vitest setup
imports @angular/compiler, so JIT is available there and nowhere else. A package can pass
all of them, typecheck cleanly, and build without error while being impossible for an Angular
application to use.
Two checks in demo/ close that gap, and both run in CI on every pull request.
Is the package consumable? No identity provider needed:
cd demo
npm install
npm run verifyThat packs the library, installs the tarball, builds the demo, and resolves every export through Angular's injector in headless Chrome. Expect:
RESULT: PASS — every export resolved through Angular DI in a real browserDoes the single-login guarantee hold? This one signs in for real, so it needs Docker:
cd demo
npm install
npm run pack-library # builds the library and installs the packed tarball
npm run keycloak # TLS certificate, container, realm configuration
npm run e2e # starts the API stub and dev server, then signs inPlaywright counts the browser's requests to Keycloak's authorization endpoint and asserts
there is exactly one, with every startup API call carrying a bearer token — including the ones
fired from a root component and a service constructor, which no route guard can reach. Add
npm run e2e:headed to watch it.
After changing library source, run npm run pack-library in demo/ and restart the dev
server — the demo consumes a packed tarball, not a symlink, and ng serve does not watch
node_modules. demo/README.md explains why, along with several
provider-side traps that produce thoroughly misleading errors.
