EST: DHIS2 iframe embedding (silent SSO)#13
Open
gqcorneby wants to merge 16 commits into
Open
Conversation
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
approved these changes
Jun 15, 2026
MatiasArriola
left a comment
There was a problem hiding this comment.
Thanks @gqcorneby! Looks good, code-review only
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
📌 References
feature/dhis2-oauth.More info about setup here: https://app.clickup.com/4528615/v/dc/4a6f7-37352/4a6f7-7232
Purpose
📝 Implementation
CspFrameAncestorsFilter— emitsContent-Security-Policy: frame-ancestors 'self' <configured origins>and suppresses anyX-Frame-Options; no-op when embedding is disabled (emptyiframe.frameAncestors).IframeCookieCustomizer— marks OB's session cookieSameSite=None; Secure(required for a cross-site frame); no-op when embedding is off.XFrameOptionsSuppressingResponse— response wrapper that drops a proxy-addedX-Frame-Optionsso CSP is the single source of truth.initiate?embedded=trueredirects withprompt=none(one-shot guard); onlogin_required/consent_requiredthe callback rendersbreakout.gsp, which navigates the top-level window to interactive login (no loop). v40 falls back to in-frame login.server.use-forward-headersso OB seeshttpsbehind a TLS proxy. Local dev TLS stack underdocker/dhis2-sso/.errorparam, null-safeid_tokenerror 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:
2.42.4.1with the skeleton installed as a real DHIS2 app — OB rendered in-frame, silent, for admin and a fresh user.resources.groovybean registration); everything else underorg.pih.warehouse.custom.iframe.*/custom/dhis2auth.See
openspec/changes/archive/2026-06-11-dhis2-iframe-embedding/validation/cross-site-cookies.mdfor the full live-validation write-up.oauth2.server.enabled=onrequiresserver.base.url(the OIDC issuer) or DHIS2 won't start.require-authorization-consent=falsefor silent SSO.Partitioned/CHIPS cookie (follow-up).📷 Screenshots & Recordings (optional)
(to be added)
🔥 Notes to the tester
d2-docker start …/dhis2-data:2.42-empty -d -p 9080 -c …/dhis2-core:2.42.4.1.oauth2.server.enabled=on+server.base.urlindhis.conf; register the OAuth client (redirect URI = OB callback, real TLD; consent off; bcrypt secret).openboxes.yml(iframe.frameAncestors= DHIS2 origin), and open the app from within DHIS2 → OB renders in-frame, no second login.