DEV Community

Akash Pal
Akash Pal

Posted on

One Login, Every Microfrontend — The Auth Architecture (Part 3)

Part 2 covered the platform's topology and the three shared microfrontends every domain team builds against. This part is authentication — the piece every other part of the platform depends on, which is why it's built early rather than left until the end.

A few terms, defined once

This part leans on some standard identity terms — worth pinning down before diving in, since they build on each other:

  • IdP (Identity Provider) — the actual service that owns usernames, passwords, and roles, and does the real work of checking whether a login is valid. Okta, Google, Microsoft Entra ID, Auth0, or a company's own login system are all examples. Host never becomes one of these itself — it always hands the real login off to one.
  • OAuth 2.0 — an industry-standard way for one app (Host) to get limited, provable confirmation from an IdP that a user is logged in, without Host ever seeing or handling that user's password.
  • OIDC (OpenID Connect) — OAuth 2.0, plus a standard way to also learn who that user is (their id, their roles), not just "yes, they logged in." This platform speaks OIDC specifically because it needs that identity information, not just a bare confirmation.
  • Issuer — the specific IdP a deployment has decided to trust. In practice, just a URL Host redirects the browser to, and later asks "did this login really happen?" A deployment can trust one issuer, or several — more on why below.

With those in hand, here's what actually happens when someone logs in.

Population-agnostic by design

This platform is explicit about scope here: the Host and Store MFE don't model "employees" vs. "partners" vs. "customers" as distinct concepts. They know one thing — a standard OIDC contract. Which population authenticates, and which provider issues their tokens, is a deployment-time config decision, not an architectural one. That's what lets the identical template back an internal admin tool, a customer-facing product, or a B2B partner portal with zero Host or Store code changes between them — the choice of IdP (Okta, Microsoft Entra ID, Auth0, Keycloak, Cognito, or a homegrown one) is explicitly out of scope for the platform itself.

One rule follows from this everywhere in the codebase: a domain MFE never triggers login, logout, or a token exchange. It reads the result of auth (useAuth(), the federated singleton from Part 2) but never causes an auth event. Host owns the entire flow, alone, end to end.

The flow: Authorization Code + PKCE

Host never sees a password. It redirects the browser to the IdP, the IdP authenticates the user, and it redirects back with proof. That handoff is called the Authorization Code flow with PKCE ("pixy" — Proof Key for Code Exchange), and it's a specific sequence of redirects:

  1. Host generates a random secret (the code verifier), hashes it into a code challenge, and sends the browser to the IdP's login page with that challenge.
  2. The user authenticates at the IdP — whatever that means for the real IdP in use.
  3. The IdP redirects back to Host's /callback with a short-lived authorization code.
  4. Host exchanges that code — with the original, un-hashed verifier — for the actual tokens.

Why the extra verifier/challenge step, specifically? A traditional server-side app can hold a permanent secret of its own — proof that "I'm really the app I claim to be" — when it trades a login code for real tokens. Host can't do that safely: it's JavaScript running inside the user's own browser, and anything shipped to a browser can be read by anyone who opens dev tools. PKCE solves this without needing any permanent secret at all. Right before redirecting to the IdP, Host invents a random, one-time value (the verifier) just for this one login attempt, and sends the IdP a scrambled version of it (the challenge). When Host later comes back to redeem the authorization code for real tokens, it presents the original, un-scrambled verifier. Only whoever actually started this specific login attempt could have that value — so the IdP can trust the exchange, without either side ever storing a password-like secret.

Here's that exact handshake as a diagram — steps 1 through 4 above, continued through to what Host does once it actually has a session:

Sequence diagram: the Host normalizes tokens from the IdP into one shape, then on navigation resolves the route against the Manifest Registry. If the route is authorized, the Host fetches the domain bundle from the CDN and mounts it into the shared singleton scope. If not authorized, the Host renders a 403 fallback and the CDN is never contacted.

And here's step 1 as real code:

// packages/runtime-module-federation/src/oidc-client.ts
export async function redirectToAuthorize(issuerConfig: IssuerConfig): Promise<void> {
  const discovery = await discover(issuerConfig.issuer);
  const verifier = generateCodeVerifier();
  const challenge = await generateCodeChallenge(verifier);
  const state = generateState();

  sessionStorage.setItem(PKCE_VERIFIER_KEY, verifier);
  sessionStorage.setItem(STATE_KEY, state);

  const url = new URL(discovery.authorization_endpoint);
  url.searchParams.set('response_type', 'code');
  url.searchParams.set('client_id', issuerConfig.clientId);
  url.searchParams.set('redirect_uri', redirectUri());
  url.searchParams.set('scope', 'openid');
  url.searchParams.set('state', state);
  url.searchParams.set('code_challenge', challenge);
  url.searchParams.set('code_challenge_method', 'S256');

  window.location.assign(url.toString());
}
Enter fullscreen mode Exit fullscreen mode

That state value is a separate, second safeguard — it guards against CSRF (a class of attack where a malicious site tries to trick a browser into completing part of this handshake without the user actually intending it). The callback rejects any response whose state doesn't match what was stored right before redirecting:

// packages/runtime-module-federation/src/oidc-client.ts
if (!returnedState || returnedState !== expectedState) {
  throw new Error('State mismatch on callback — possible CSRF, aborting token exchange');
}
Enter fullscreen mode Exit fullscreen mode

One or more issuers — a config array from line one

Recall an issuer is just the specific IdP a deployment trusts. platform.config.json lists every issuer a deployment trusts, as an array. A deployment serving one population declares one issuer; a deployment serving many tenants might declare several, or point at a single identity provider that itself manages logins for many upstream organizations — Host's own code doesn't need to change either way, only the config.

This platform makes a specific, deliberate choice here: multi-issuer support is a config array from the first line of code, even with only one issuer configured — retrofitting an array shape onto a single-issuer assumption later would touch every piece of code that reads a user's claims.

// platform.config.json
{
  "orgName": "acme-demo",
  "branding": { "primaryColor": "#2F5CE0", "logoUrl": "/logo.svg" },
  "idp": {
    "issuers": [
      { "id": "local-dev", "issuer": "http://localhost:9000", "clientId": "host-dev-client", "default": true }
    ],
    "issuerResolution": "default"
  },
  "manifestUrl": "/platform.manifest.json"
}
Enter fullscreen mode Exit fullscreen mode

issuerResolutiondefault / email-domain / subdomain / selector — decides which issuer applies when more than one is configured. Only default ships built with a UI; the other three are documented config swaps, not something built upfront on spec.

One normalized session, regardless of which IdP produced it

Here's the problem this solves: two different IdPs describing the exact same logged-in user are free to shape that description differently — some send roles as an array, some send a single role; some send org_id, some orgId. Without a fix, every single piece of downstream code — every domain team's page, every route guard — would need to know which specific IdP produced whatever token it's looking at, forever. Instead, one function collapses whatever comes back into one fixed shape, immediately after the token exchange:

// packages/runtime-module-federation/src/normalize.ts
export function normalizeTokenResponse(raw: RawTokenResponse): AuthState {
  const idToken = typeof raw.id_token === 'string' ? raw.id_token : undefined;
  const claimsSource = idToken ? decodeJwtPayload(idToken) : {};

  const roles = Array.isArray(claimsSource.roles)
    ? claimsSource.roles
    : typeof claimsSource.role === 'string'
      ? [claimsSource.role]
      : [];
  const orgId = claimsSource.org_id ?? claimsSource.orgId;

  return {
    accessToken: raw.access_token,
    idToken: idToken ?? null,
    claims: { roles, orgId: typeof orgId === 'string' ? orgId : '', userId: claimsSource.sub! },
    expiresAt: Date.now() + raw.expires_in * 1000,
  };
}
Enter fullscreen mode Exit fullscreen mode

Everything downstream — every domain MFE, every route guard, StoreContract itself — reads only this normalized AuthState shape:

// packages/types/src/auth.ts
export interface AuthState {
  accessToken: string | null;
  idToken: string | null;   // kept specifically as id_token_hint for RP-Initiated Logout, below
  claims: AuthClaims | null;
  expiresAt: number | null;
}
Enter fullscreen mode Exit fullscreen mode

That's the entire payoff: every team writes its authorization checks once, against one shape, and never needs to know or care which real IdP a given deployment actually uses. It's also the seam worth its own unit tests — asserting the normalized shape stays identical across at least two mock issuer response formats — since swapping one IdP for another later should be a change contained entirely to this one function.

Session persistence and cross-tab logout

The resulting AuthState is handed to the Store MFE's AuthProvider from Part 2 — every domain MFE reading useAuth() gets this exact session object. It's also deliberately persisted to sessionStorage, hydrated back on mount, so a page reload doesn't force a full round trip back to the IdP:

// apps/store-mfe/src/auth/AuthContext.tsx
function loadPersistedSession(): AuthState {
  try {
    const raw = sessionStorage.getItem(SESSION_STORAGE_KEY);
    if (!raw) return EMPTY_AUTH_STATE;
    const parsed = JSON.parse(raw) as AuthState;
    if (!parsed.expiresAt || parsed.expiresAt <= Date.now()) return EMPTY_AUTH_STATE;
    return parsed;
  } catch {
    return EMPTY_AUTH_STATE;
  }
}
Enter fullscreen mode Exit fullscreen mode

sessionStorage, deliberately, not localStorage — a session shouldn't outlive the browser tab it belongs to. Cross-tab logout is handled separately, via BroadcastChannel('auth-events'): when one tab logs out, every other open tab hears a LOGOUT message, clears its own session, and — because that clear runs through the same state-setting path — the persisted sessionStorage copy gets wiped too, in every tab, automatically. That message is { type: 'LOGOUT' | 'TOKEN_REFRESHED' }, and it's treated as a must-have from day one, not a nice-to-have: a domain MFE silently holding a stale session after a Host logout is the kind of bug that's invisible until a real incident.

Logout means telling the IdP, not just forgetting locally

This is easy to get half-right: "logging out" has to mean more than clearing Host's own memory. If Host only resets its own state, the IdP's own session cookie is untouched — the very next login attempt can silently re-authenticate against that still-live IdP session without ever showing a sign-in prompt. That's single sign-on working exactly as designed; it's just not "log out" if only the local half of the relationship gets torn down.

The correct mechanism is RP-Initiated Logout — a named part of the OIDC spec — redirecting the browser to the IdP's end_session_endpoint so the IdP itself ends the session:

// packages/runtime-module-federation/src/oidc-client.ts
export async function redirectToEndSession(
  issuerConfig: IssuerConfig,
  idTokenHint: string | null,
): Promise<void> {
  const discovery = await discover(issuerConfig.issuer);
  if (!discovery.end_session_endpoint) {
    window.location.assign(postLogoutRedirectUri());
    return;
  }
  const url = new URL(discovery.end_session_endpoint);
  if (idTokenHint) url.searchParams.set('id_token_hint', idTokenHint);
  url.searchParams.set('client_id', issuerConfig.clientId);
  url.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri());
  window.location.assign(url.toString());
}
Enter fullscreen mode Exit fullscreen mode

id_token_hint is the raw ID token from earlier — the IdP uses it to confirm which session it's being asked to end, which is exactly why AuthState keeps the raw token around instead of discarding it once its claims are decoded. And the IdP has to allow-list post_logout_redirect_uris, the same way it already allow-lists login redirect URIs, so it knows it's safe to send the browser back afterward. With all three pieces together, logging out genuinely ends the session at the IdP — the next "Log in" click shows a real sign-in prompt, not an instant, invisible re-authentication.

Authorization is claims-driven, never issuer-driven

Role/claim data — never which IdP authenticated the user — drives every enforcement point: route guards in Host's manifest-driven router, conditional rendering inside domain MFEs, and enforcement at the backend layer, which is a hard requirement, not a suggestion — client-side checks are never sufficient alone. This decoupling is what lets the same authorization code work whether a "role" came from a workforce group, a partner-org assignment, or a subscription tier; the platform doesn't distinguish, and doesn't need to. Feature flags layer on top of roles for progressive rollout, sourced from the Store MFE's FeatureFlagProvider.

From login to a mounted page: the whole sequence

Putting the pieces above in order, this is the complete path from "user opens the app" to "a team's page is on screen" — the diagram earlier in this part covers steps 1 through 6:

  1. User loads the platform → Host checks for a valid in-memory token.
  2. If absent, redirect to the IdP (Authorization Code + PKCE) → exchange code for tokens.
  3. Host normalizes the response and initializes the Store MFE's AuthProvider with the session.
  4. User navigates to a domain route → Host resolves it against the manifest.
  5. Host checks the route's required role against the user's claims.
  6. If authorized: fetch the domain's bundle from the CDN, mount it into the shared singleton scope — no re-fetch of React/Store/Components, no duplicate provider instance.
  7. If not authorized: Host renders a 403 fallback. The CDN is never contacted — the domain's bundle is never fetched at all.

Modeling roles in the local dev IdP

Exercising role-gating locally needs at least two distinct roles without a real IdP account. The local IdP — a thin, dev-only wrapper around the oidc-provider library, never meant to be deployed — models this with two named accounts and a generic fallback:

// tools/mock-idp/src/server.js
const NAMED_ACCOUNTS = new Map(
  [DEMO_ACCOUNT, ADMIN_ACCOUNT].map((account) => [account.accountId, account]),
);

async function findAccount(_ctx, id) {
  const account = NAMED_ACCOUNTS.get(id) ?? { accountId: id, roles: ['user'], orgId: 'acme-demo' };
  return {
    accountId: account.accountId,
    async claims() {
      return { sub: account.accountId, roles: account.roles, org_id: account.orgId };
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

Any login typed into the local sign-in form works: admin-user-1 resolves to the admin role, demo-user-1 — or literally anything else — resolves to user. It accepts any password, too. This is exclusively a local stand-in for exercising the flow end to end, never a real identity source.

Part 4 is how this all actually ships: scaffolding a new domain team from the template, and the CI/CD pipeline that carries a page from a pull request to production.

Next: Part 4 — Shipping It: Scaffolding & CI/CD

Top comments (0)