GSoC 2026: O3 Frontend Architecture for Authentication Module Integration

Hey @jayasanka @ibacher,

Following up on my earlier post — I’ve since done a deeper dive into the auth module codebase and @twiine’s thread. Rather than repeat the backend analysis, I want to focus on the frontend side that hasn’t been discussed yet.

Quick background: I’m a final-year CSE student at IIITDM Kancheepuram, currently interning at Attack Capital (YC W-22) where I’m building HealOS, a healthcare EHR platform. Day job involves Stripe billing infrastructure, AI document pipelines, and yes, I migrated our entire auth system from Clerk to Firebase, handling MFA flows, token management, and session state machines in a React SPA. That experience is directly relevant here. Been contributing to O3 since October 2025 across esm-patient-chart and esm-patient-management — @jayasanka reviewed my O3-5272 work and pushed me to rewrite it from hardcoded to template-driven, which honestly made the solution much better.

Where O3 breaks today

The login flow in openmrs-esm-core sends a Basic header to /ws/rest/v1/session and treats any 401 as “wrong credentials.” But with the auth module active, a 401 + Location header means “primary auth passed, go complete this challenge.” openmrsFetch doesn’t check for the Location header — so the user just sees a login error with no idea they need to enter a TOTP code.

How the frontend flow should work

Following @ibacher’s guidance about not exposing auth state:

  1. openmrsFetch detects 401 + Location → navigates SPA to that route (e.g. /spa/login/totp)
  2. Challenge component renders, user submits, POSTs JSON to REST endpoint
  3. Another 401 + Location? → follow it. 200 with session? → done, enter the app.

The frontend stays completely stateless — it just follows the backend’s Location headers. UserLogin and TwoFactorAuthenticationScheme already manage the state machine server-side.

I built something very similar at my current job — migrated auth from Clerk to Firebase in a React SPA, handling MFA intermediate states without storing sensitive auth state on the client. Same pattern, different codebase.

Forced password change

Since @ibacher noted a 401 doesn’t fit here (user IS authenticated), what about a requirePasswordChange: true flag in the session response? The login app checks it after successful auth and routes to /spa/login/change-password. No filter changes, no redirect — just a flag in a response we already read.

Backward compatibility

If there’s no Location header on a 401, everything works exactly as today. MFA routes stay dormant. Zero impact on deployments not using this module.

Questions

  1. REST endpoints for TOTP/secret question JSON submission — in this module (extending PR #18’s TotpController), or in openmrs-module-webservices.rest?

  2. requirePasswordChange flag in session response — viable approach?

  3. Should 401 + Location interception be global in openmrsFetch or scoped to login only?

cc @twiine @mseaton

Following up on this thread my GSoC 2026 proposal for this project was submitted on Mar 31, but I wanted to keep the technical discussion moving.

A couple of frontend questions for @jayasanka and @janithrs that came up while I was prototyping:

  1. The current openmrs-esm-primary-navigation-app login flow expects the legacy JSESSIONID + 302 redirect. With the auth-module moving toward the SPA-friendly Location-header + JSON contract (ref PR #29 work), what’s the preferred interim strategy — feature-flag the new client, or version the auth response and let the frontend negotiate?

  2. For requirePasswordChange: true in the auth response, does the O3 side want this routed through an extension slot (so distros can override the reset UI), or a hard-coded post-login interstitial?

  3. For 2FA/TOTP challenge flows, leaning toward client polling the auth status endpoint vs. EventSource — any preference from a module-side perspective?

I’ve been sketching these on a fork of esm-primary-navigation. Happy to open a draft PR anchored to this thread if it’s useful.

Praneeth

Very interesting conversation, particularly regarding the 401 + Location flow for maintaining the server-driven authentication state machine. I believe this is elegant because it allows the frontend to stay mostly stateless and follow the challenge transitions.

+1 for the question about whether interception should be done globally for openmrsFetch versus being login-specific.

I’ve also been looking into the O3/auth flow, so I’ll be following along here. I’m happy to help out on the frontend side if needed.

— Ash

Thanks @ashthe25 glad you’re looking into this too.

On the global vs login-specific interception question: my lean is global, for one reason. Any authenticated request can receive a 401 + Location challenge mid-session session expiry, step-up auth, privilege escalation triggers. A login-specific interceptor would miss those and punt the user to a generic error screen with no recovery path. The scoping risk is making sure we don’t accidentally intercept 401s that aren’t auth-module challenges. If there’s no Location header, that’s a genuine auth failure and openmrsFetch should hand it back to the caller as it does today.

If you want a concrete starter that would actually help: there’s no existing regression test in openmrs-esm-core that exercises the 401-redirect path in openmrsFetch against a mocked auth-module response. Writing one would give us a guard for whatever the final design lands on, and it’s a scoped piece that doesn’t depend on the rest of the architecture being decided.

Following up on the Auth integration thread. Been reading through openmrs-module-authentication today with ibacher’s “maintain the same interfaces” framing in mind.

One scoping question: the existing AuthenticationScheme implementations (Basic, Totp, SecretQuestion, and the new EmailAuthenticationScheme from AUT-20) already express the server-side flow well. The less-settled piece seems to be the response shape for multi-step auth when the caller is a SPA @sudeshkar #37 prototypes one direction with an SPA-friendly failure response.

For the 2026 project, is the intent to (a) keep the schemes untouched and adapt O3 to the existing loginLocation redirect model, (b) standardize a JSON response contract around AuthenticationContext transitions that the frontend consumes, or (c) both in sequence? Asking because the scoping changes where most of the 12-week work lives.

@jayasanka

Hi @jayasanka , I’m also following this closely as the author of PR #37. Happy to clarify the design decisions behind it and adjust based on your feedback on the preferred direction.

Thanks for the direction. I’ll write the regression test for the 401-redirect path in openmrsFetch against a mocked auth-module response and open a PR. Will share once it’s ready.

Hi @praneeth622, I went ahead and added those regression tests for the 401-redirect path in openmrsFetch . PR here: (test) Add regression tests for 401 Location-header redirect in openmrsFetch by AshThe25 · Pull Request #1754 · openmrs/openmrs-esm-core · GitHub — covers both the Location-header redirect and the fallback case. Let me know if the test coverage looks right to you

webservices.rest provides the core functionality for a REST layer and mappings for stuff in core. Anything not in core is handled elsewhere.

This feels like it’s introducing a lot of machinery for very little benefit. A user with the requirePasswordChange must change their password before they can take any other actions in the system.

Should 401 + Location interception be global in openmrsFetch or scoped to login only?

It kind of needs to be global, at least for anything that hits any endpoint in OpenMRS. Otherwise, we can never expire sessions, require users to enter passwords or re-authenticated, etc.

@ibacher makes sense regarding the global — an authenticated endpoint can initiate a session challenge; restricting the scope to the login will completely ignore any mid-session expiration.

Regarding #1754, the redirecting of the interceptor to the Location header’s URL should include the window.location.href as the returnUrl parameter, which will help to redirect the user once authenticated. The login component currently uses the location.state.referrer technique — just wondering whether it should include this mechanism too or whether the redirect is beyond its scope.

Thanks @ibacher, that clarifies things:

  1. TOTP / secret-question JSON in webservices.rest rather than the auth module. Makes sense, keeps the 2026 frontend work scoped to consuming REST endpoints instead of building REST inside the auth module.

  2. On requirePasswordChange: fair point, the extension-slot idea was overkill. Dropping it. On that response we just hard-route to the password-change page, and nothing else is navigable until it’s cleared.

  3. Global 401 + Location interception in openmrsFetch: aligned. That’s the only way session expiry and step-up challenges surface correctly, not just fresh logins.

Also @ashthe25 thanks for opening #1754, scope looks right. Quick thought on your returnUrl question: I’d encode returnUrl=window.location.href into the Location redirect as a query param. The current location.state.referrer pattern only survives inside the login router’s state, so a session expiry on an arbitrary page would lose it. Encoding it in the URL covers both fresh-login and mid-session paths with one mechanism.

@praneeth622 I agree with you, we should encode it in the URL itself. If we use location.state.referrer, then it will exist only in the memory of the router and therefore, if the session expires halfway through navigating to a page, then the path to go back will be lost. We could encode returnUrl=window.location.href in the URL itself when using openmrsFetch.

Quick update on the Basic auth parsing side of the integration work: openmrs-module-authentication#48 goes in today. Two concrete fixes:

  1. Authorization header prefix match is now case-insensitive per RFC 7235 §2.1 (previously a lowercase "basic " would fail).
  2. Decoded credentials split on the first colon only, so passwords containing “:” are preserved (RFC 7617 §2).

Tests cover both. openmrs-esm-core#1751 (O3-4020, context sync on first render) merged on Apr 20 and unblocks the useAppContext path we discussed on the RFC

Thanks @ibacher for the clarification. I’ve been following this closely as the author of PR #37. I’ll be updating the PR this Saturday to align with the global Location-header interception approach and move the TOTP endpoint to webservices.rest. Happy to discuss any further design decisions in the meantime.

+1 on moving the TOTP endpoint to webservices.rest consistent with how the rest of the auth surface routes through there. On the Location-header interception side, is the intent to intercept at the framework level in esm-framework or closer to the fetch layer? The answer probably decides whether the frontend needs its own redirect guard for cross-tab session expiry.

This is the exact opposite of what I meant. All secondary authentication factors run through the authentication module and the REST endpoints should remain there. REST cannot depend on authentication and it should not implement authentication functionality on its own.

As a general principle, supporting the authentication module in O3 should only require a minimal set of changes to the authentication module and no other backend code.

Cross-tab session expiry: #1754 addresses single tab scenario, where 401 + Location header fires navigate() call to authenticate URL. Cross tab session expiry would require listening to BroadcastChannel or storage events in the framework. This seems to be an extension area after we are comfortable with single tab scenario.

Apologies @ibacher — I misread the earlier discussion. PR #37 keeps all authentication endpoints within the auth module. The only change is a minimal update to AuthenticationFilter to support SPA clients. I’ll update the PR on Saturday to reflect that scope clearly.

Agreed on webservices.rest as the target for the TOTP endpoint, aligning with how the rest of the auth surface is already routed there makes the frontend side much simpler to reason about.

On the Location-header interception: one detail that tripped me up while working on esm-core#1751 is that the redirect target often arrives before the SPA has a hydrated session, so useAppContext used to return a stale/empty value on the first render after the redirect. #1751 fixes that synchronous-first-render case, but it doesn’t cover cross-tab session expiry, if tab A redirects and tab B still holds a stale JSESSIONID, B’s next fetch 401s without any shared signal.

Two questions on the scheme you’re landing on:

  1. Is the 401 → Location redirect meant to be handled purely at the fetch wrapper layer, or does the esm-framework session store also need to observe it so subscribers can flush?
  2. For TOTP specifically, is the intent a two-step flow (password → 302 → TOTP endpoint), or are both credentials submitted in a single authenticate() call?

FWIW, cross-tab session expiry is not something I’d worry about for now. We don’t have a pro-active form of session expiry at all.

does the esm-framework session store also need to observe it

It really depends on what the 401 indicates…

Two-step flow, but you can’t use a 3xx response with fetch, hence it will probably look something more like a 200 response with a Location header.