npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 Pending review, and nothing has been published yet.

Installation

npm install @centeva/msal-angular

Install peer dependencies if not already present:

npm install @azure/msal-angular @azure/msal-browser

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

  1. Your app starts and fires several API calls at once — a guard, a resolver, a root component's ngOnInit, a service constructor.
  2. Each one hits MsalInterceptor. Each acquireTokenSilent fails, because there is no token yet.
  3. 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.
  4. The losers throw interaction_in_progress. Their error cleanup deletes the winner's PKCE verifier and clears the interaction flag.
  5. 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.
  6. 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 redirectUri agree 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 set redirectUri to a path like https://app/auth, the auth route can never match — hash routing only sees https://app/#/auth. The redirect component never activates, and redirect handling silently falls through to whatever else happens to call handleRedirectObservable(). It usually still works, which is what makes it hard to spot.

Either use a path-shaped redirectUri with 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 canActivate array 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 NG04002 before 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_hint only when the caller passes it. It never derives it from the account, and never sends client_id on 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 postLogoutRedirectUri without id_token_hint is 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() and provideAuth() 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 call provideOidcAuth() 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:

  • oidcAuthGuard returns true in 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_FN still 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 verify

That 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 browser

Does 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 in

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