Skip to content

EST: DHIS2 iframe embedding (silent SSO)#13

Open
gqcorneby wants to merge 16 commits into
feature/dhis2-oauthfrom
feature/dhis2-iframe
Open

EST: DHIS2 iframe embedding (silent SSO)#13
gqcorneby wants to merge 16 commits into
feature/dhis2-oauthfrom
feature/dhis2-iframe

Conversation

@gqcorneby

@gqcorneby gqcorneby commented Jun 11, 2026

Copy link
Copy Markdown

📌 References

More info about setup here: https://app.clickup.com/4528615/v/dc/4a6f7-37352/4a6f7-7232

Purpose

  • Embed OpenBoxes inside a DHIS2 app (iframe) so a logged-in DHIS2 user reaches OB with no second login — seamless silent SSO.
  • Make OB framable only by the configured DHIS2 origin, and keep its session cookie working inside the frame.

ℹ️ Silent SSO is v42-only. It relies on the OIDC prompt=none flow, which only the v42 profile (Spring Authorization Server / OIDC) supports. v40/v41 (legacy UAA OAuth 2.0) do not support prompt=none, so no silent attempt is made on those profiles — they fall back to in-frame interactive login.

📝 Implementation

  • CspFrameAncestorsFilter — emits Content-Security-Policy: frame-ancestors 'self' <configured origins> and suppresses any X-Frame-Options; no-op when embedding is disabled (empty iframe.frameAncestors).
  • IframeCookieCustomizer — marks OB's session cookie SameSite=None; Secure (required for a cross-site frame); no-op when embedding is off.
  • XFrameOptionsSuppressingResponse — response wrapper that drops a proxy-added X-Frame-Options so CSP is the single source of truth.
  • v42 silent SSO: embedded initiate?embedded=true redirects with prompt=none (one-shot guard); on login_required/consent_required the callback renders breakout.gsp, which navigates the top-level window to interactive login (no loop). v40 falls back to in-frame login.
  • server.use-forward-headers so OB sees https behind a TLS proxy. Local dev TLS stack under docker/dhis2-sso/.
  • Code-review fixes folded in: sanitize the logged OAuth error param, null-safe id_token error message, added contract tests.

✨ Description of Change

Lets OB run inside a DHIS2 app via iframe with silent SSO. The embedder is the DHIS2 origin itself, so DHIS2's session cookie is first-party; only OB's cookie is cross-site (hence SameSite=None; Secure). When embedding is disabled (default), OB responses are byte-for-byte unchanged from upstream.

Key design decisions:

  • Embedder must be the DHIS2 origin (a DHIS2 app). Validated end-to-end against a local DHIS2 2.42.4.1 with the skeleton installed as a real DHIS2 app — OB rendered in-frame, silent, for admin and a fresh user.
  • Upstream isolation — one surgical edit (resources.groovy bean registration); everything else under org.pih.warehouse.custom.iframe.* / custom/dhis2auth.
  • Graceful break-out — silent failure escapes the frame to a top-level login instead of dead-ending on DHIS2's un-framable login page.

See openspec/changes/archive/2026-06-11-dhis2-iframe-embedding/validation/cross-site-cookies.md for the full live-validation write-up.

⚠️ Deployment notes

  • DHIS2 oauth2.server.enabled=on requires server.base.url (the OIDC issuer) or DHIS2 won't start.
  • OAuth client must have require-authorization-consent=false for silent SSO.
  • Third-party-cookie blocking (Safari/Incognito/Chrome rollout) breaks the cross-site OB cookie. Deploy OB on the same parent domain as DHIS2 (recommended), or add a Partitioned/CHIPS cookie (follow-up).

📷 Screenshots & Recordings (optional)

(to be added)

🔥 Notes to the tester

  1. Local DHIS2 (Tomcat-10 core): d2-docker start …/dhis2-data:2.42-empty -d -p 9080 -c …/dhis2-core:2.42.4.1.
  2. Set oauth2.server.enabled=on + server.base.url in dhis.conf; register the OAuth client (redirect URI = OB callback, real TLD; consent off; bcrypt secret).
  3. Install the DHIS2 app, configure openboxes.yml (iframe.frameAncestors = DHIS2 origin), and open the app from within DHIS2 → OB renders in-frame, no second login.

gqcorneby added 16 commits June 11, 2026 12:33
Extend the dhis2-iframe-embedding change with a silent-SSO phase, grounded
in a source-level investigation of DHIS2 prompt=none support:

- validation/prompt-none.md: v42 (Spring Authorization Server 1.5.x) honors
  OIDC prompt=none; legacy v40/UAA does not.
- proposal/design D5: in-frame prompt=none flow with break-out on
  login_required/consent_required; v40 login-once fallback; third-party
  cookie risk; consent prerequisite.
- specs/dhis2-auth: ADDED 'Embedded silent authentication' requirement.
- tasks Phase 5: implementation + Spock tests + live prompt=none validation.
Tested against DHIS2 2.42.4.1: no-session -> login_required;
live-session -> silent authorization code, zero UI. Silent SSO viable.
Custom servlet filter emits Content-Security-Policy: frame-ancestors scoped
to openboxes.custom.iframe.frameAncestors (default 'self'), and a response
wrapper suppresses any X-Frame-Options so CSP is the single framing source.
Registered in resources.groovy after the Sentry filter. No in-repo
X-Frame-Options source exists, so no upstream neutralization needed.
…(Phase 5)

Embedded entry point (/oauth/dhis2/initiate?embedded=true) runs the v42 OAuth
flow with prompt=none when iframe embedding is enabled; v40 ignores it (no
silent support). On login_required/consent_required the callback renders a
break-out page that navigates window.top to interactive login, so DHIS2's
unframeable login renders top-level. A one-shot session flag prevents loops.
All custom/dhis2auth + custom/iframe specs green.
- CSP filter now no-ops when frameAncestors is empty, so non-embedding
  deployments stay byte-for-byte upstream (no new header by default).
- IframeCookieCustomizer installs Tomcat Rfc6265CookieProcessor with
  SameSite=None on OB's embedded Tomcat when embedding is enabled — reliable
  for JSESSIONID (a header-rewriting filter cannot reach it). Scoped no-op
  when disabled; warns that embedding requires TLS so SameSite=None is Secure.
  No DHIS2 change, no Tomcat upgrade.
…se 3-4)

server.use-forward-headers documented in the client template (set via
openboxes.yml, not upstream application.yml). docker/dhis2-sso/ adds an
nginx-TLS + OpenBoxes + mysql compose stack under *.localtest.me for live
embedded/silent-SSO testing against a real DHIS2 (scaffolding, pending a
manual run-through).
D1: CSP filter gated off by default. D2: pivot from the unreliable
Set-Cookie filter to Rfc6265CookieProcessor (reaches JSESSIONID). Upstream
touch points corrected to the single file actually edited (resources.groovy).
- breakout.gsp: JS-encode the interactive URL (encodeAsJavaScript) and only
  navigate window.top when actually framed, recovering in place top-level.
- Strengthen cookie customizer test to assert Rfc6265 SameSite=None value.
- Correct design rollback note (filter no-ops, not 'self').
The iframe CookieCustomizer and CSP filter declared `GrailsApplication
grailsApplication` but were registered in resources.groovy without wiring
it. Spring-DSL beans there are not autowired by name, so grailsApplication
was null. IframeCookieCustomizer.customize() runs at embedded-Tomcat
creation, so the null dereference aborted boot (NPE) for every deployment,
embedding enabled or not. Wire grailsApplication explicitly via ref().
Add the iframe embedding block to the committed per-client config reference
with placeholder values, alongside the existing DHIS2 OAuth example. Notes
the SameSite=None/Secure requirement (TLS + use-forward-headers in prod,
session.cookie.secure only for localhost testing). No secrets.
Make the forwarded-proto setting visible in the committed per-client config
reference (root-level server.use-forward-headers), alongside the frameAncestors
example. Required behind a TLS proxy so OB marks the SameSite=None cookie Secure.
No secrets.
- sanitize attacker-influenceable `error` param before logging (CRLF strip + cap)
- null-safe fallback for malformed id_token exception message
- drop redundant @transactional (Grails services are transactional by default)
- collapse redundant prepareAuthorize ternary (default param covers it)
- add tests: login_required must not break out when not silent; tombstoned
  link whose user is already inactive; full-URL assertion for PKCE-omit
Confirmed in-frame silent SSO end-to-end on a local same-site rig (DHIS2
2.42.4.1 + skeleton installed as a DHIS2 app + OB on lvh.me): production
topology is "embedder is a DHIS2 app", so DHIS2's session cookie is
first-party and SameSite is a non-issue; only OB's cookie is cross-site.

Captured DHIS2 config requirements: oauth2.server.enabled requires
server.base.url, consent must be off for prompt=none, redirect URI needs a
real TLD, DHIS2 2.42 needs a Tomcat-10 core. Documented the third-party
cookie limitation (state mismatch when 3p cookies blocked) and its fixes
(same-parent-domain deploy or Partitioned/CHIPS).
Full ./gradlew test green (973 tests, 0 failures). 3.2 folded into 5.7; 4.5 superseded by the skeleton-as-DHIS2-app rig; 5.7 updated with confirmed in-frame success. Frontend untouched; local npm test blocked by Node-18/Babel env (CI on Node 14 covers it).
Archive dhis2-iframe-embedding to archive/2026-06-11-dhis2-iframe-embedding.
Apply spec deltas to permanent specs: dhis2-auth (+1 embedded-silent-auth
requirement), iframe-embedding (new capability), local-dev-tls (new capability).
Also fix pre-existing structural issues in openspec/specs/dhis2-auth/spec.md
(delta headers in a main spec; SHALL keyword on the second line) so it
validates.
Revert the simplify-pass removal. The AST-based @transactional reliably binds the Hibernate session for flush() in private methods; the default Grails wrapping does not, causing TransactionRequiredException at runtime (per oauth-branch commit 4aa42c9). Unit DataTest specs don't catch this — restore + comment so it isn't removed again.

@MatiasArriola MatiasArriola left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @gqcorneby! Looks good, code-review only

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants