diff --git a/docker/dhis2-sso/README.md b/docker/dhis2-sso/README.md new file mode 100644 index 00000000000..e35341a5260 --- /dev/null +++ b/docker/dhis2-sso/README.md @@ -0,0 +1,100 @@ +# Local TLS dev stack — DHIS2 iframe embedding + silent SSO + +Exercises the full embedded flow (CSP `frame-ancestors`, `SameSite=None; Secure` +session cookie, v42 `prompt=none` silent SSO) against a real DHIS2 instance, +behind locally-trusted TLS — no public domain required. + +> **Status:** scaffolding. Run through it end-to-end once and capture any gotchas +> back here (tasks 4.5 / 5.7). Treat the compose/nginx files as a starting point. + +## Why TLS locally + +`SameSite=None` cookies are **rejected by browsers unless `Secure`**, and `Secure` +needs HTTPS. nginx terminates TLS and forwards `X-Forwarded-Proto: https`, so OB +(with `server.use-forward-headers=true`) marks the session cookie `Secure`. Both +hosts sit under `*.localtest.me` (which resolves to `127.0.0.1` with no +`/etc/hosts` edits), giving OB and DHIS2 a shared parent domain. + +## 1. Trust + generate certs (mkcert) + +```bash +# install mkcert (https://github.com/FiloSottile/mkcert), then: +mkcert -install +cd docker/dhis2-sso/certs +mkcert openboxes.localtest.me +mkcert dhis2.localtest.me +``` + +Certs land in `./certs/` and are git-ignored. + +## 2. Point at your DHIS2 + +```bash +export DHIS2_UPSTREAM=http://172.16.0.99:18081 # your DHIS2 instance +``` + +The nginx `dhis2.localtest.me` server block proxies to it, so DHIS2 and OB share +the `localtest.me` parent domain. + +## 3. Configure OB (`openboxes.yml`) + +Place an `openboxes.yml` next to this file (git-ignored — never commit secrets) +enabling embedding, forwarded-proto, and the v42 OAuth client: + +```yaml +server: + use-forward-headers: true +openboxes: + custom: + iframe: + frameAncestors: ['https://dhis2.localtest.me'] + dhis2: + oauth: + enabled: true + profile: v42 + clientId: openboxes-dev + clientSecret: # never commit + authorizeUrl: https://dhis2.localtest.me/oauth2/authorize + tokenUrl: https://dhis2.localtest.me/oauth2/token + redirectUri: https://openboxes.localtest.me/openboxes/oauth/dhis2/callback + scopes: openid username + clientAuth: basic +``` + +## 4. Register the OB client in DHIS2 + +In DHIS2, the OAuth2 client (`clientId: openboxes-dev`) needs: + +- `redirectUris`: `https://openboxes.localtest.me/openboxes/oauth/dhis2/callback` +- `scopes`: `openid,username` +- `clientAuthenticationMethods`: `client_secret_basic` +- **For zero-click silent SSO:** `require-authorization-consent = false` + (DHIS2 defaults it to `true`, which makes the first silent attempt return + `consent_required` until the user consents once). See + `../../openspec/changes/dhis2-iframe-embedding/validation/prompt-none.md`. + +## 5. Run + +```bash +docker compose -f docker/dhis2-sso/docker-compose.yml up +``` + +Then embed `https://openboxes.localtest.me/openboxes/oauth/dhis2/initiate?embedded=true` +in a DHIS2 dashboard iframe and watch a logged-in DHIS2 user land in OB with no +second login. + +## Troubleshooting + +- **Blank iframe / CSP error in DevTools console** — `frameAncestors` does not + list the embedding origin (`https://dhis2.localtest.me`). +- **Logged out immediately inside the frame** — the session cookie is being + blocked. Check it is `SameSite=None; Secure` (DevTools → Application → Cookies) + and that the browser is not blocking third-party cookies. If blocked, the + shared-parent-domain setup (both under `localtest.me`) should make it + first-party; confirm both hosts resolve through nginx. +- **`prompt=none` shows a login page instead of returning silently** — the DHIS2 + user has no live session, or `require-authorization-consent` is still `true` + and consent has not been granted. +- **Login fails even top-level** — embedding enabled without TLS/forwarded-proto; + the `SameSite=None` cookie is dropped. Ensure `server.use-forward-headers=true` + and access via `https://openboxes.localtest.me`. diff --git a/docker/dhis2-sso/certs/.gitignore b/docker/dhis2-sso/certs/.gitignore new file mode 100644 index 00000000000..ab27e47671e --- /dev/null +++ b/docker/dhis2-sso/certs/.gitignore @@ -0,0 +1,3 @@ +# mkcert-issued certs are generated per-developer — never commit them. +* +!.gitignore diff --git a/docker/dhis2-sso/docker-compose.yml b/docker/dhis2-sso/docker-compose.yml new file mode 100644 index 00000000000..64e799058ec --- /dev/null +++ b/docker/dhis2-sso/docker-compose.yml @@ -0,0 +1,51 @@ +# Local TLS dev stack for DHIS2 iframe embedding + silent-SSO testing. +# nginx terminates TLS in front of OpenBoxes so the SameSite=None; Secure cookie +# and CSP frame-ancestors flow behave as in production. DHIS2 is treated as an +# external instance (proxied via nginx -> DHIS2_UPSTREAM) — set it to your DHIS2. +# +# Prereqs: see README.md (mkcert certs into ./certs, /etc/hosts not needed for +# *.localtest.me). Bring up with: docker compose -f docker/dhis2-sso/docker-compose.yml up +version: '3.8' + +services: + nginx: + image: nginx:1.25-alpine + environment: + # Point this at your DHIS2 instance (must be reachable from the nginx container). + DHIS2_UPSTREAM: ${DHIS2_UPSTREAM:-http://host.docker.internal:18081} + ports: + - "443:443" + volumes: + - ./nginx.conf:/etc/nginx/templates/nginx.conf.template:ro + - ./certs:/etc/nginx/certs:ro + command: > + /bin/sh -c "envsubst '$$DHIS2_UPSTREAM' < /etc/nginx/templates/nginx.conf.template + > /etc/nginx/nginx.conf && nginx -g 'daemon off;'" + depends_on: + - openboxes + extra_hosts: + - "host.docker.internal:host-gateway" + + openboxes: + image: openboxes/openboxes:latest + environment: + # External OB config: enable iframe embedding + forwarded-proto. The mounted + # openboxes.yml supplies the DHIS2 OAuth client settings (v42, prompt=none). + JAVA_OPTS: "-Dgrails.env=production" + volumes: + - ./openboxes.yml:/home/openboxes/.grails/openboxes.yml:ro + depends_on: + - openboxes-db + + openboxes-db: + image: mysql:8 + environment: + MYSQL_ROOT_PASSWORD: openboxes + MYSQL_DATABASE: openboxes + MYSQL_USER: openboxes + MYSQL_PASSWORD: openboxes + volumes: + - openboxes-db-data:/var/lib/mysql + +volumes: + openboxes-db-data: diff --git a/docker/dhis2-sso/nginx.conf b/docker/dhis2-sso/nginx.conf new file mode 100644 index 00000000000..39a321da5d8 --- /dev/null +++ b/docker/dhis2-sso/nginx.conf @@ -0,0 +1,48 @@ +# TLS-terminating reverse proxy for local iframe + silent-SSO testing. +# Fronts OpenBoxes (and, optionally, DHIS2) behind https://*.localtest.me so the +# SameSite=None; Secure session cookie and CSP frame-ancestors flow can be +# exercised exactly as in production. *.localtest.me resolves to 127.0.0.1. + +events { } + +http { + # Pass the real scheme so OB (server.use-forward-headers=true) marks cookies Secure. + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + + # OpenBoxes + server { + listen 443 ssl; + server_name openboxes.localtest.me; + + ssl_certificate /etc/nginx/certs/openboxes.localtest.me.pem; + ssl_certificate_key /etc/nginx/certs/openboxes.localtest.me-key.pem; + + location / { + proxy_pass http://openboxes:8080; + # WebSocket upgrade (Grails async / live reload) + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + } + + # DHIS2 — proxy to your DHIS2 instance so it shares the localtest.me parent domain + # with OB (first-party cookies). Point DHIS2_UPSTREAM at your instance, e.g. the + # live test server, then register the OB OAuth client redirectUri against + # https://openboxes.localtest.me/openboxes/oauth/dhis2/callback. + server { + listen 443 ssl; + server_name dhis2.localtest.me; + + ssl_certificate /etc/nginx/certs/dhis2.localtest.me.pem; + ssl_certificate_key /etc/nginx/certs/dhis2.localtest.me-key.pem; + + location / { + # Override with your DHIS2 host (e.g. http://172.16.0.99:18081). + proxy_pass ${DHIS2_UPSTREAM}; + } + } +} diff --git a/docker/openboxes.client-template.yml b/docker/openboxes.client-template.yml index a12fe233bf6..c68b412a1f1 100644 --- a/docker/openboxes.client-template.yml +++ b/docker/openboxes.client-template.yml @@ -246,11 +246,22 @@ openboxes: # ------------------------------------------------------------------------- # Iframe embedding / CSP frame-ancestors (dhis2-iframe-embedding) - # Set frameAncestors to the DHIS2 origin(s) that will embed OB. - # Also enable server.use-forward-headers if behind a TLS-terminating proxy. - # ------------------------------------------------------------------------- - # iframe: - # frameAncestors: [] # e.g. ['https://dhis2.example.com'] + # frameAncestors: the DHIS2 origin(s) allowed to embed OB in an iframe. + # Empty/unset (default) => `frame-ancestors 'self'` (no third-party embedding), + # behaviourally equivalent to the old X-Frame-Options: SAMEORIGIN. + # Setting a non-empty list also flips the session cookie to + # `SameSite=None; Secure` and turns on the v42 silent-SSO entry point + # (/oauth/dhis2/initiate?embedded=true). v40 falls back to in-frame login. + # ------------------------------------------------------------------------- + # custom: + # iframe: + # frameAncestors: [] # e.g. ['https://dhis2.example.com'] + +# Forwarded-proto: REQUIRED when embedding behind a TLS-terminating proxy, so OB +# knows the request is HTTPS and stamps the SameSite=None session cookie Secure +# (browsers reject SameSite=None without Secure). Root-level key, not under openboxes. +# server: +# use-forward-headers: true # ------------------------------------------------------------------------- # LDAP (optional — enable only for clients using corporate auth) diff --git a/docker/openboxes.yml b/docker/openboxes.yml index 69b5f99c99a..cdcd9b9e43e 100644 --- a/docker/openboxes.yml +++ b/docker/openboxes.yml @@ -7,7 +7,7 @@ openboxes: # Client registered in DHIS2 during dhis2-oauth-spike. # Redirect URI must match exactly what is registered in DHIS2 OAuth2 Clients. # ------------------------------------------------------------------------- - custom: + custom: dhis2: oauth: enabled: false @@ -17,3 +17,23 @@ openboxes: tokenUrl: https://dev.eyeseetea.com/play/uaa/oauth/token userUrl: https://dev.eyeseetea.com/play/api/me redirectUri: http://localhost:8080/openboxes/oauth/dhis2/callback + + # ------------------------------------------------------------------------- + # DHIS2 iframe embedding (dhis2-iframe-embedding) + # frameAncestors: DHIS2 origin(s) allowed to embed OB in an iframe. Empty/unset + # (default) => frame-ancestors 'self' (no third-party embedding). A non-empty + # list also gates silent SSO (prompt=none) and switches the session cookie to + # SameSite=None. SameSite=None needs Secure: in production serve OB over TLS with + # `server.use-forward-headers: true` (below); for plain-HTTP localhost testing + # only, add `server.session.cookie.secure: true` (never ship that to a real host). + # ------------------------------------------------------------------------- + iframe: + frameAncestors: [] # e.g. ['https://dhis2.example.com'] or ['http://localhost:8081'] + +# Forwarded-proto — root-level key, a sibling of `openboxes` (NOT under it). +# REQUIRED when OB runs behind a TLS-terminating proxy (the iframe-embedding case): it +# lets OB trust X-Forwarded-Proto so it stamps the SameSite=None session cookie Secure +# and builds https self-links. Harmless when not behind a proxy. See +# openboxes.client-template.yml for the full forwarded-proto note. +server: + use-forward-headers: true diff --git a/grails-app/conf/spring/resources.groovy b/grails-app/conf/spring/resources.groovy index be5178f57bf..142fd2949ed 100644 --- a/grails-app/conf/spring/resources.groovy +++ b/grails-app/conf/spring/resources.groovy @@ -5,6 +5,8 @@ import org.springframework.boot.web.servlet.FilterRegistrationBean import org.springframework.core.Ordered import org.pih.warehouse.monitoring.SentryGrailsTracingFilter +import org.pih.warehouse.custom.iframe.CspFrameAncestorsFilter +import org.pih.warehouse.custom.iframe.IframeCookieCustomizer // This is where we can register spring-specific beans using the Spring Bean DSL. // Regular beans that conform to Grails conventions don't need to be registered here. @@ -19,4 +21,21 @@ beans = { order = Ordered.HIGHEST_PRECEDENCE + 1 } productValidator(ProductValidator) + + // Custom: DHIS2 iframe embedding — emit CSP frame-ancestors, suppress X-Frame-Options. + cspFrameAncestorsFilter(CspFrameAncestorsFilter) { + grailsApplication = ref('grailsApplication') + } + cspFrameAncestorsFilterRegistration(FilterRegistrationBean) { + filter = cspFrameAncestorsFilter + urlPatterns = ['/*'] + order = Ordered.HIGHEST_PRECEDENCE + 2 + } + + // Custom: DHIS2 iframe embedding — SameSite=None session cookie when embedding is enabled. + // grailsApplication must be wired explicitly: resources.groovy DSL beans are not + // autowired by name, and this customizer runs at container-creation time (very early). + iframeCookieCustomizer(IframeCookieCustomizer) { + grailsApplication = ref('grailsApplication') + } } diff --git a/grails-app/controllers/org/pih/warehouse/custom/dhis2auth/Dhis2OAuthController.groovy b/grails-app/controllers/org/pih/warehouse/custom/dhis2auth/Dhis2OAuthController.groovy index aac8cf89fd7..eaf812f41bd 100644 --- a/grails-app/controllers/org/pih/warehouse/custom/dhis2auth/Dhis2OAuthController.groovy +++ b/grails-app/controllers/org/pih/warehouse/custom/dhis2auth/Dhis2OAuthController.groovy @@ -10,6 +10,10 @@ class Dhis2OAuthController { static allowedMethods = [initiate: 'GET', callback: 'GET', pending: 'GET'] + // OIDC errors a prompt=none attempt returns when interactive login/consent is needed. + private static final List SILENT_INTERACTION_ERRORS = + ['login_required', 'consent_required', 'interaction_required'] + GrailsApplication grailsApplication Dhis2OAuthService dhis2OAuthService Dhis2RegistrationService dhis2RegistrationService @@ -25,7 +29,11 @@ class Dhis2OAuthController { String state = UUID.randomUUID().toString() session.dhis2OAuthState = state - Dhis2OAuthService.AuthorizeRequest authRequest = dhis2OAuthService.prepareAuthorize(state) + // Embedded entry point (iframe src = .../initiate?embedded=true) attempts silent SSO on v42. + boolean silent = params.boolean('embedded') && iframeEmbeddingEnabled && dhis2OAuthService.silentAuthSupported + session.dhis2OAuthSilent = silent + + Dhis2OAuthService.AuthorizeRequest authRequest = dhis2OAuthService.prepareAuthorize(state, silent) session.dhis2OAuthCodeVerifier = authRequest.codeVerifier redirect(url: authRequest.url) } @@ -33,21 +41,39 @@ class Dhis2OAuthController { def callback() { String code = params.code String state = params.state + String error = params.error - if (!code || !state) { - response.status = 400 - render "Bad request: missing code or state" - return - } - if (state != session.dhis2OAuthState) { + if (!state || state != session.dhis2OAuthState) { response.status = 400 render "Bad request: state mismatch" return } + boolean wasSilent = session.dhis2OAuthSilent as boolean session.dhis2OAuthState = null + session.dhis2OAuthSilent = null String codeVerifier = session.dhis2OAuthCodeVerifier session.dhis2OAuthCodeVerifier = null + if (error) { + // A silent prompt=none attempt that needs interaction: break out of the iframe to a + // top-level interactive login (the break-out page omits prompt=none, so no loop). + if (wasSilent && SILENT_INTERACTION_ERRORS.contains(error)) { + render(view: '/custom/dhis2auth/breakout') + return + } + // Reason: error is attacker-influenceable (OAuth error param) — strip CR/LF and cap length to prevent log forging. + log.warn "dhis2_oauth_authorize_error error=${error?.replaceAll(/[\r\n]/, ' ')?.take(100)}" + flash.message = "DHIS2 login failed. Please try again or contact an administrator." + redirect(controller: 'auth', action: 'login') + return + } + + if (!code) { + response.status = 400 + render "Bad request: missing code" + return + } + try { AccessToken token = dhis2OAuthService.exchangeCode(code, codeVerifier) Dhis2User dhis2User = dhis2OAuthService.resolveIdentity(token) @@ -82,4 +108,9 @@ class Dhis2OAuthController { private boolean isOauthEnabled() { grailsApplication.config.openboxes.custom.dhis2.oauth.enabled as boolean } + + private boolean isIframeEmbeddingEnabled() { + List ancestors = (grailsApplication.config.openboxes.custom.iframe.frameAncestors ?: []) as List + ancestors as boolean + } } diff --git a/grails-app/services/org/pih/warehouse/custom/dhis2auth/Dhis2OAuthService.groovy b/grails-app/services/org/pih/warehouse/custom/dhis2auth/Dhis2OAuthService.groovy index 4709a2c6fbf..e91bf2c09c6 100644 --- a/grails-app/services/org/pih/warehouse/custom/dhis2auth/Dhis2OAuthService.groovy +++ b/grails-app/services/org/pih/warehouse/custom/dhis2auth/Dhis2OAuthService.groovy @@ -36,6 +36,7 @@ class Dhis2OAuthService { private static final String DEFAULT_SCOPES_V40 = 'ALL' private static final String DEFAULT_SCOPES_V42 = 'openid username' private static final String CODE_CHALLENGE_METHOD = 'S256' + private static final String PROMPT_NONE = 'none' private static final int PKCE_VERIFIER_BYTES = 32 private static final int HTTP_OK = 200 @@ -59,13 +60,18 @@ class Dhis2OAuthService { connectionManager?.close() } - AuthorizeRequest prepareAuthorize(String state) { + AuthorizeRequest prepareAuthorize(String state, boolean silent = false) { String codeVerifier = isV42() ? generateCodeVerifier() : null String challenge = codeVerifier ? codeChallengeFor(codeVerifier) : null - new AuthorizeRequest(url: buildAuthorizeUrl(state, challenge), codeVerifier: codeVerifier) + new AuthorizeRequest(url: buildAuthorizeUrl(state, challenge, silent), codeVerifier: codeVerifier) } - String buildAuthorizeUrl(String state, String codeChallenge = null) { + // Reason: only v42 (OIDC) honours prompt=none; v40/UAA ignores it, so silent is a no-op there. + boolean isSilentAuthSupported() { + isV42() + } + + String buildAuthorizeUrl(String state, String codeChallenge = null, boolean silent = false) { boolean v42 = isV42() String defaultScopes = v42 ? DEFAULT_SCOPES_V42 : DEFAULT_SCOPES_V40 String scopes = config.scopes ?: defaultScopes @@ -78,6 +84,9 @@ class Dhis2OAuthService { // codeChallenge is base64url-no-pad ([A-Za-z0-9_-]) — already URL-safe, no encoding needed url += "&code_challenge=${codeChallenge}&code_challenge_method=${CODE_CHALLENGE_METHOD}" } + if (v42 && silent) { + url += "&prompt=${PROMPT_NONE}" + } url } @@ -134,7 +143,7 @@ class Dhis2OAuthService { String payload = new String(Base64.urlDecoder.decode(padBase64(parts[1])), CHARSET) (JSON.parse(payload) as Map).sub as String } catch (Exception e) { - throw new Dhis2OAuthException("User info fetch failed: malformed id_token — ${e.message}", e) + throw new Dhis2OAuthException("User info fetch failed: malformed id_token — ${e.message ?: e.class.simpleName}", e) } } diff --git a/grails-app/services/org/pih/warehouse/custom/dhis2auth/Dhis2RegistrationService.groovy b/grails-app/services/org/pih/warehouse/custom/dhis2auth/Dhis2RegistrationService.groovy index 2af49053ae1..79ed41fdc6d 100644 --- a/grails-app/services/org/pih/warehouse/custom/dhis2auth/Dhis2RegistrationService.groovy +++ b/grails-app/services/org/pih/warehouse/custom/dhis2auth/Dhis2RegistrationService.groovy @@ -5,6 +5,9 @@ import org.pih.warehouse.core.User import org.pih.warehouse.custom.dhis2auth.Dhis2OAuthService.Dhis2OAuthException import org.pih.warehouse.custom.dhis2auth.Dhis2OAuthService.Dhis2User +// Reason: the AST-based @Transactional reliably binds the Hibernate session for +// flush() calls in private methods; the default Grails wrapping does not, which +// caused TransactionRequiredException at runtime. Do not remove. @Transactional class Dhis2RegistrationService { diff --git a/grails-app/views/custom/dhis2auth/breakout.gsp b/grails-app/views/custom/dhis2auth/breakout.gsp new file mode 100644 index 00000000000..eee5ce1813f --- /dev/null +++ b/grails-app/views/custom/dhis2auth/breakout.gsp @@ -0,0 +1,30 @@ + + + + + <warehouse:message code="dhis2auth.breakout.title" default="Signing in…"/> + + + + +

+ + + diff --git a/openspec/changes/archive/2026-06-11-dhis2-iframe-embedding/design.md b/openspec/changes/archive/2026-06-11-dhis2-iframe-embedding/design.md new file mode 100644 index 00000000000..ed282d3f6bc --- /dev/null +++ b/openspec/changes/archive/2026-06-11-dhis2-iframe-embedding/design.md @@ -0,0 +1,275 @@ +## Context + +Embedding OB inside a DHIS2 dashboard requires a CSP `frame-ancestors` +directive that names specific third-party origins, plus a session cookie that +survives a cross-site request. + +Whether OB also emits a conflicting `X-Frame-Options` header today is +**determined by the spike** (`dhis2-oauth-spike` task 1.7 → +`validation/xframeoptions-source.txt`). A pre-audit grep of the repo found no +in-source `X-Frame-Options`, so the most likely outcome is: + +- **No in-repo source.** The header may come from a deploy-level proxy (nginx + in the dev stack, customer-side reverse proxy in production), or simply not + be set at all. Either way, the iframe filter just ADDS CSP — no upstream + code to neutralize. +- **Less likely**: a Grails plugin or Tomcat default is emitting it. The + spike artifact pins this down. If it's emitted, our filter strips it on + the way out (still no upstream-code edit needed — same filter, just an + extra response-header removal). + +The embedded Tomcat in Grails 3.3.16 / Spring Boot 1.5 is Tomcat 8.5.88 +(confirmed by `dhis2-oauth-spike` validation/tomcat-version.txt). +`Rfc6265CookieProcessor.sameSiteCookies` was backported to Tomcat 8.5 in +8.5.47 — it IS technically available at 8.5.88. However, the cookie path +remains a `Set-Cookie`-rewriting Servlet `Filter` (D2) because the filter +approach requires no Tomcat XML config changes and is easier to toggle at +runtime per the `iframe.frameAncestors` flag. The Rfc6265CookieProcessor +path is an available fallback if the filter proves problematic. + +Constraints: upstream-isolation (new code under `org.pih.warehouse.custom.*`, +surgical edits to upstream files), JDK 8 / Groovy 2.4 / Grails 3.3 floors. + +## Goals / Non-Goals + +**Goals:** +- OB pages embeddable in iframes whose top-level origin matches a configured + DHIS2 allow-list; safely default-deny when the list is empty. +- Session cookie `SameSite=None; Secure` when embedding is enabled. +- `Secure` cookies set correctly behind a TLS-terminating proxy + (`X-Forwarded-Proto: https`). +- Local TLS dev stack so developers can exercise the iframe flow against a + real DHIS2 instance without owning a public domain. + +**Goals (added for silent SSO):** +- A DHIS2-authenticated user opening an embedded OB screen lands authenticated + with no second login, on the `v42` profile (silent `prompt=none` flow). +- `v40` embedded users degrade gracefully to a one-time in-frame login. + +**Non-Goals:** +- The base OAuth/SSO flow itself (owned by `dhis2-oauth-core` / v42; this change + only adds the *embedded* silent variant on top of it). +- Deep-linking from DHIS2 dashboard items into OB screens. +- Seamless re-entry into the originating DHIS2 dashboard after a break-out + interactive login — the user returns via the DHIS2 dashboard, at which point + the frame loads silently. (No automatic frame re-entry in v1.) +- Iframe-aware re-auth handling when DHIS2 access tokens expire (out of v1 + scope across all three changes; mitigation: configure long-lived DHIS2 + tokens). + +## Decisions + +### D1: CSP `frame-ancestors`, not `X-Frame-Options` + +`X-Frame-Options: SAMEORIGIN` cannot allow specific third-party origins. +`Content-Security-Policy: frame-ancestors` can, and is the modern +replacement. We emit it from a custom filter that runs on all HTML responses, +configured via `iframe.frameAncestors` in `application.yml`. We also remove +any upstream-set `X-Frame-Options` header to avoid the two contradicting +each other. + +**Gated to preserve upstream default (revised).** Earlier this decision emitted +`frame-ancestors 'self'` on every response by default. To keep non-embedding +deployments byte-for-byte identical to upstream (OB ships no framing header +today), the filter now **no-ops entirely when `openboxes.custom.iframe.frameAncestors` +is empty** and only emits the directive (and the X-Frame-Options suppression) +when the allow-list is non-empty. Config lives in `openboxes.yml` external +config under `openboxes.custom.iframe.frameAncestors`, not upstream +`application.yml`. + +### D2: `SameSite=None` via Rfc6265CookieProcessor on OB's embedded Tomcat (revised) + +**Revised from the original `Set-Cookie`-rewriting filter.** Verifying against +the actual code path showed a header-rewriting filter cannot reliably stamp the +one cookie that matters: Tomcat serialises `JSESSIONID` through its internal +`CookieProcessor` at response commit, not via `addHeader`/`setHeader`, so a +filter never sees it. The filter would amend app-set cookies but silently miss +the session cookie. + +Instead, an `EmbeddedServletContainerCustomizer` (`IframeCookieCustomizer`, +custom code) installs Tomcat's `Rfc6265CookieProcessor` with +`sameSiteCookies=None` on **OB's own embedded Tomcat 8.5.88** when embedding is +enabled. This is the only Tomcat 8.5 processor that can emit `SameSite`, it is +already bundled (no Tomcat upgrade, no DHIS2 change, no XML), and it reliably +covers `JSESSIONID`. + +- Scoped: no-op when `frameAncestors` is empty — OB keeps its default cookie + processor, so non-embedding deployments are unchanged. +- `Secure` (required alongside `SameSite=None`) comes from D3 forwarded-proto: + once OB knows it is behind TLS, Tomcat stamps `JSESSIONID` Secure. +- Footgun guard: the customizer logs a loud WARN that embedding requires TLS / + `server.use-forward-headers`, since `SameSite=None` without `Secure` is + dropped by browsers and would break login. +- Trade-offs (accepted): switching to `Rfc6265CookieProcessor` applies stricter + RFC6265 cookie parsing for that deployment, and `SameSite=None` applies to all + OB cookies (cross-site embedding inherently requires this; OB's own CSRF + tokens remain the defence). WAR-on-external-Tomcat deployments must set the + equivalent in that Tomcat's `context.xml`. + +### D3: Forwarded-proto via Spring Boot's existing setting + +Spring Boot 1.5 supports `server.use-forward-headers=true` (verified in +`dhis2-oauth-spike` validation/spring-boot-1.5-forward-headers.txt), which +configures Tomcat's `RemoteIpValve`. Setting it makes `request.isSecure()` +reflect the proxy's `X-Forwarded-Proto` header. We enable it via a profile +or conditional only when iframe embedding is enabled, to avoid changing +behavior for deployments without a TLS-terminating proxy. + +### D4: Local dev stack as a separate `docker/dhis2-sso/` directory + +Avoid touching `docker/docker-compose.yml` (upstream file). New directory +contains: +- `docker-compose.yml` — services: `nginx`, `dhis2`, `dhis2-db` (postgres), + `openboxes`, `openboxes-db` (mysql). +- `nginx.conf` — two `server` blocks (443/TLS) proxying to internal services, + WebSocket upgrade headers, `X-Forwarded-Proto`, `X-Forwarded-For`, `Host`. +- `certs/.gitignore` — committed empty dir; certs generated by the developer. +- `README.md` — mkcert install, cert generation command, OAuth client + registration walkthrough in DHIS2 (referencing the spike's setup notes), + troubleshooting. + +Hostnames: `dhis2.localtest.me` and `openboxes.localtest.me`. `*.localtest.me` +is reserved by RFC 6761 / IANA and resolves to `127.0.0.1` automatically — +no `/etc/hosts` edits required for most resolvers (confirmed by +`dhis2-oauth-spike` validation/localtest-resolution.txt; corporate-DNS +workaround documented in the README). + +The compose file in this change supersedes the spike's +`docker-compose.spike.yml` (which only had DHIS2 + postgres). Once this +change lands, the spike's file can be deleted as part of `dhis2-oauth-spike` +archival. + +### D5: In-frame silent SSO (v42) with break-out on interactive login + +Grounded in `validation/prompt-none.md`: DHIS2 v42 (Spring Authorization Server +1.5.x) honors OIDC `prompt=none`; legacy v40/UAA does not. + +**v42 silent flow.** When embedding is enabled and an unauthenticated request +arrives, the custom login redirect (the existing `dhis2auth` interceptor / +controller path, not upstream) sends the user to the DHIS2 authorize endpoint +with `prompt=none` added to the normal PKCE + `scope=openid username` request. +`Dhis2OAuthService.buildAuthorizeUrl` gains an optional `prompt` argument; no +other OAuth code changes. On a live DHIS2 session with consent already granted, +DHIS2 returns a `code` and the existing callback establishes the session — zero +UI. + +**Error handling = break out of the frame.** DHIS2 returns `prompt=none` +failures as `error=login_required` / `consent_required` / `interaction_required` +on the redirect back to OB's callback. OB must NOT respond by redirecting the +*frame* to DHIS2's interactive login — DHIS2 serves its own `X-Frame-Options`, +so that would render blank. Instead the callback returns a minimal break-out +page whose script sets `window.top.location` to the interactive authorize URL +(same request, `prompt` omitted). DHIS2's login then renders top-level; after +login the OB session cookie (`SameSite=None; Secure`) is set, and the user +returns to the DHIS2 dashboard, where the frame now loads silently. + +**Loop guard.** A persistent `login_required` must not bounce authorize↔callback +forever. A one-shot marker (a short-lived session flag or a `silent=tried` +query param echoed through `state`) ensures at most one `prompt=none` attempt +per navigation before falling through to the break-out. + +**Detecting "embedded".** OB cannot see framing server-side. We treat embedding +as active when `iframe.frameAncestors` is non-empty; that config is the single +switch. (The break-out page additionally guards with `window.top === window.self` +on the client so a top-level visit never breaks itself out.) + +**Consent prerequisite (v42).** For true zero-click, the OB OAuth client in +DHIS2 must be registered with `requireAuthorizationConsent=false` (DHIS2 +defaults it to `true` — `validation/prompt-none.md`). Otherwise the first +embedded visit per user breaks out once for consent, then is silent thereafter. + +**v40.** No `prompt=none` path. The interceptor makes no silent attempt for +profile `v40`; the user logs in once inside the frame and the `SameSite=None` +cookie carries the session forward. + +## Risks / Trade-offs + +- **`SameSite=None` cookie rewriting** depends on the `Set-Cookie` header + being unset before our filter runs. If upstream code commits the cookie + via a pre-baked `Set-Cookie` string mid-response (rare in Grails), the + filter would not be able to amend it. Mitigation: filter runs late in the + chain; if a real case shows up, document the operational workaround + ("front OB with nginx that rewrites `Set-Cookie`"). +- **Origin allow-list misconfiguration** silently breaks the iframe. The + browser shows the CSP violation in DevTools console. Mitigation: prominent + troubleshooting section in the dev stack README. +- **DHIS2 OAuth2 client setup varies by DHIS2 version**: the README pins the + DHIS2 image tag (same tag used in `dhis2-oauth-spike`). +- **Refresh tokens out of scope across all three changes**: in an iframe, + the re-auth bounce may break out of the iframe when tokens expire. + Mitigation: configure long-lived DHIS2 access tokens, or accept the + re-auth bounce in v1. Document in the README. +- **Third-party cookies block silent SSO (D5).** The OB session cookie is + third-party relative to the DHIS2 host page. `SameSite=None; Secure` (D2) + covers browsers that still permit third-party cookies, but Chrome's + third-party-cookie phase-out can block the in-frame session entirely — + silent `prompt=none` would succeed yet the resulting cookie would not be + sent on the next embedded request. Mitigations, in order of preference: + (a) deploy OB and DHIS2 under a shared parent domain so the cookie is + first-party; (b) `Partitioned` cookies (CHIPS) once viable on the target + Tomcat/browser matrix; (c) accept that fully-blocked browsers fall back to + the in-frame login. This is environment-dependent — confirm against the + customer's actual browser + domain topology during the live test. +- **`prompt=none` behavior is library-confirmed but not yet live-verified.** + `validation/prompt-none.md` flags spring-security #18647 (a 7.x regression + that should not affect the 6.5.x line DHIS2 2.42 ships). The three-case + manual test must pass on the target DHIS2 before relying on D5. + +## Migration Plan + +1. Land code with `iframe.frameAncestors = []` defaults — zero behavior + change for existing deployments. +2. No schema changes — no Liquibase work. +3. Per-deployment opt-in: set the allow-list of DHIS2 origins in + env-specific config and enable `server.use-forward-headers` if behind a + TLS-terminating proxy. +4. Rollback: clear `frameAncestors`. The CSP filter becomes a no-op and emits + no framing header, and the cookie customizer leaves OB's default cookie + processor in place — reverting fully to OB's upstream default. + +## Upstream touch points + +The entire feature touches **one** upstream file (better than originally +predicted — no `application.yml` edit, no X-Frame-Options neutralization): + +- `grails-app/conf/spring/resources.groovy` — registers the CSP filter and the + cookie customizer beans (an import + a few lines in the `beans {}` block). The + bean classes themselves are custom (`org.pih.warehouse.custom.iframe`). + +Dropped vs the original plan: +- `application.yml` — NOT edited. `frameAncestors` and + `server.use-forward-headers` live in `openboxes.yml` external config; the CSP + filter defaults to a no-op when unset. +- X-Frame-Options neutralization — NOT needed. A repo grep confirmed OB emits no + in-repo `X-Frame-Options`; the response wrapper suppresses any that a proxy + adds, with no upstream-code edit. + +The silent-SSO work (D5) touches **only this fork's own custom files** — no +new upstream touch points: +- `org.pih.warehouse.custom.dhis2auth.Dhis2OAuthService` — optional `prompt` + on the authorize URL. +- The `dhis2auth` login interceptor / `Dhis2OAuthController` callback — + embedded silent redirect, one-shot loop guard, and the break-out-on-error + response. + +Everything else lives under the new custom package and `docker/dhis2-sso/`. + +## Confidence: 9/10 + +Re-scored after `dhis2-oauth-spike` validation artifacts landed. + +All assumptions confirmed: +- Tomcat 8.5.88 — filter-based cookie rewrite path confirmed (D2) ✓ +- No OB in-repo source of `X-Frame-Options` — conditional D1 touch-point + drops to zero for in-repo code ✓ +- `server.use-forward-headers=true` property name confirmed (D3) ✓ +- `*.localtest.me` resolves to 127.0.0.1 on this machine (D4) ✓ +- Config belongs in `docker/openboxes.yml`, not `application.yml` ✓ + +Note: DHIS2 itself already emits a `frame-ancestors` CSP on its own +responses (for the DHIS2 UI). This is DHIS2-side and does not affect OB's +filter, which controls OB's own response headers. + +Residual risk: the `Set-Cookie` rewrite filter cannot amend cookies committed +mid-response by upstream code (rare in Grails). Mitigation documented: front +OB with nginx that rewrites `Set-Cookie` as a last resort. diff --git a/openspec/changes/archive/2026-06-11-dhis2-iframe-embedding/proposal.md b/openspec/changes/archive/2026-06-11-dhis2-iframe-embedding/proposal.md new file mode 100644 index 00000000000..86f0275f91e --- /dev/null +++ b/openspec/changes/archive/2026-06-11-dhis2-iframe-embedding/proposal.md @@ -0,0 +1,96 @@ +## Why + +Operators deploying OpenBoxes alongside DHIS2 want to embed OB screens inside +DHIS2 dashboards/apps via iframe so users do not have to context-switch tabs. +Today OB serves `X-Frame-Options: SAMEORIGIN`, which blocks iframe embedding +from any other origin, and the session cookie is set without `SameSite=None`, +which would prevent it being sent on cross-site requests inside an iframe +even if the framing were allowed. + +This change owns the response-header, cookie, and forwarded-proto changes +needed to make iframe embedding work, a local TLS dev stack to test the flow +end-to-end without a public domain, AND the in-frame SSO behavior that makes +the embedded experience seamless: a DHIS2-authenticated user opening an +embedded OB screen should land authenticated without a second login. + +The header/cookie/proxy plumbing is independent of how users log in. The +seamless in-frame SSO, however, builds on the DHIS2 OAuth flow +(`dhis2-oauth-core` / v42) — it is the OAuth flow run silently inside the +frame. This change therefore branches from and depends on the DHIS2 OAuth +work. + +**This change also depends on `dhis2-oauth-spike` being archived** — +`design.md` Decisions reference its `validation/` artifacts (embedded-Tomcat +version, `Rfc6265CookieProcessor.sameSiteCookies` availability). The silent-SSO +design is grounded in `validation/prompt-none.md` (source-level confirmation +that DHIS2 v42 / Spring Authorization Server honors OIDC `prompt=none`, and +that legacy v40 / UAA does not). + +## What Changes + +- Replace `X-Frame-Options` with `Content-Security-Policy: frame-ancestors` + scoped to a configured allow-list of DHIS2 origins (default empty → behaves + equivalently to `frame-ancestors 'self'`). +- Mark the session cookie `SameSite=None; Secure` when iframe embedding is + enabled (allow-list non-empty). Unchanged otherwise. +- Honor `X-Forwarded-Proto` so OB recognizes it is behind a TLS-terminating + proxy and sets `Secure` cookies correctly. +- Provide a local Docker dev stack under `docker/dhis2-sso/`: nginx + terminating TLS in front of `dhis2` and `openboxes` containers, mkcert-issued + certs, `*.localtest.me` hostnames so the OAuth + iframe flow can be tested + faithfully without a public domain. +- **Seamless in-frame SSO (v42):** when embedding is enabled and an + unauthenticated request arrives, OB silently runs the OAuth Authorization + Code flow with OIDC `prompt=none`. If the user has a live DHIS2 session (and + the OB client skips consent), OB establishes a session with zero UI. If DHIS2 + returns `login_required` / `consent_required`, OB breaks out of the frame for + a one-time interactive login rather than rendering DHIS2's login page inside + the frame (which DHIS2's own `X-Frame-Options` would block). +- **v40 fallback (login-once-in-frame):** legacy UAA does not support + `prompt=none` (`validation/prompt-none.md`), so v40 embedded users log in + once inside the frame (DHIS2 SSO button or local credentials); the + `SameSite=None` session cookie then keeps them authenticated on later loads. + +Out of scope: deep-linking from DHIS2 dashboard items into specific OB screens; +seamless re-entry into the *same* dashboard after a break-out interactive login +(the user returns via the DHIS2 dashboard, at which point the frame loads +silently); iframe-aware re-auth handling when access tokens expire. + +## Capabilities + +### New Capabilities +- `iframe-embedding`: CSP `frame-ancestors`, `SameSite=None; Secure` cookies, + forwarded-proto handling, configurable DHIS2 origin allow-list. +- `local-dev-tls`: docker-compose stack with nginx + mkcert + DHIS2 + OB for + faithful local OAuth/iframe testing. + +### Modified Capabilities +- `dhis2-auth`: adds embedded silent authentication — when OB is framed and + embedding is enabled, the v42 OAuth flow runs with `prompt=none` and breaks + out of the frame on `login_required`/`consent_required`; v40 falls back to + in-frame login. The non-embedded (top-level) login flow is unchanged. + +## Impact + +- **New custom backend code** under `org.pih.warehouse.custom.dhis2auth.iframe` + (or a sibling package — same custom isolation rules): CSP filter, cookie + rewrite filter (if needed per spike), forwarded-headers config. +- **Custom backend edits (this fork's own files, not upstream)** under + `org.pih.warehouse.custom.dhis2auth`: `Dhis2OAuthService` gains an optional + `prompt` on the authorize URL; the controller/interceptor gains the + embedded-silent-redirect + loop-guard + break-out-on-error handling. +- **Upstream touch points**: + - `grails-app/conf/application.yml` — config keys for + `iframe.frameAncestors` and forwarded-headers, with defaults that preserve + upstream behavior. + - The filter or interceptor that emits `X-Frame-Options` today (location + confirmed by `dhis2-oauth-spike` task 1.7) — replace with the + CSP-emitting filter, behind the allow-list flag. +- **New dev-environment files** under `docker/dhis2-sso/`: `docker-compose.yml`, + `nginx.conf`, `README.md`, `certs/.gitignore`. Does not modify the existing + `docker/docker-compose.yml`. +- **No new Grails plugins**. +- **No Liquibase changesets**. +- **Operational**: deployments embedding OB in DHIS2 must terminate TLS on a + domain reachable by both DHIS2 and the user's browser, and configure the + DHIS2 origin allow-list. Documented in the change's `design.md`. diff --git a/openspec/changes/archive/2026-06-11-dhis2-iframe-embedding/specs/dhis2-auth/spec.md b/openspec/changes/archive/2026-06-11-dhis2-iframe-embedding/specs/dhis2-auth/spec.md new file mode 100644 index 00000000000..49150d1e0fa --- /dev/null +++ b/openspec/changes/archive/2026-06-11-dhis2-iframe-embedding/specs/dhis2-auth/spec.md @@ -0,0 +1,34 @@ +## ADDED Requirements + +### Requirement: Embedded silent authentication +The system SHALL establish an OB session without user interaction when +OpenBoxes is served inside a DHIS2 iframe (embedding enabled via a non-empty +`iframe.frameAncestors`), an unauthenticated request arrives, and the +configured DHIS2 profile supports silent authentication (`v42`). Where the +profile does not support it (`v40`), the system SHALL fall back to in-frame +interactive login. The non-embedded (top-level) login flow SHALL remain +unchanged from the base DHIS2 OAuth behavior. + +#### Scenario: v42 silent success with a live DHIS2 session +- **WHEN** embedding is enabled, `custom.dhis2.oauth.profile = v42`, the request has no OB session, the user has a live DHIS2 session, and the OB OAuth client skips consent (or consent was previously granted) +- **THEN** OB redirects to the DHIS2 authorize endpoint with `prompt=none` (plus the usual PKCE challenge and `scope=openid username`), DHIS2 returns an authorization `code` with no UI, and OB establishes the session and renders the requested screen inside the frame + +#### Scenario: v42 no DHIS2 session breaks out for interactive login +- **WHEN** embedding is enabled, profile is `v42`, and the `prompt=none` authorize attempt returns `error=login_required` (or `interaction_required`) to the callback +- **THEN** OB does NOT render DHIS2's login page inside the frame; instead it returns a break-out response that navigates the top-level window (`window.top`) to the interactive authorize URL (no `prompt=none`) + +#### Scenario: v42 consent required is treated as a break-out +- **WHEN** the `prompt=none` attempt returns `error=consent_required` because the OB client was registered with `requireAuthorizationConsent=true` and the user has not yet consented +- **THEN** OB breaks out of the frame for the one-time interactive consent, after which subsequent `prompt=none` attempts succeed silently + +#### Scenario: silent attempt runs at most once (no redirect loop) +- **WHEN** a `prompt=none` attempt has already been made for the current navigation and returns an error again +- **THEN** OB does NOT issue another silent `prompt=none` redirect for that navigation (a one-shot guard prevents an authorize↔callback loop); it proceeds to the break-out interactive login + +#### Scenario: v40 falls back to in-frame login +- **WHEN** embedding is enabled and `custom.dhis2.oauth.profile = v40` (or unset) +- **THEN** OB makes NO silent `prompt=none` attempt; the unauthenticated user is shown the OB login (DHIS2 SSO button or local credentials) inside the frame, and after login the `SameSite=None; Secure` session cookie keeps the session active on later embedded loads + +#### Scenario: non-embedded login is unchanged +- **WHEN** `iframe.frameAncestors` is empty (embedding disabled), regardless of profile +- **THEN** the login flow is identical to the base DHIS2 OAuth behavior — no `prompt=none`, no break-out response diff --git a/openspec/changes/dhis2-iframe-embedding/specs/iframe-embedding/spec.md b/openspec/changes/archive/2026-06-11-dhis2-iframe-embedding/specs/iframe-embedding/spec.md similarity index 100% rename from openspec/changes/dhis2-iframe-embedding/specs/iframe-embedding/spec.md rename to openspec/changes/archive/2026-06-11-dhis2-iframe-embedding/specs/iframe-embedding/spec.md diff --git a/openspec/changes/dhis2-iframe-embedding/specs/local-dev-tls/spec.md b/openspec/changes/archive/2026-06-11-dhis2-iframe-embedding/specs/local-dev-tls/spec.md similarity index 100% rename from openspec/changes/dhis2-iframe-embedding/specs/local-dev-tls/spec.md rename to openspec/changes/archive/2026-06-11-dhis2-iframe-embedding/specs/local-dev-tls/spec.md diff --git a/openspec/changes/archive/2026-06-11-dhis2-iframe-embedding/tasks.md b/openspec/changes/archive/2026-06-11-dhis2-iframe-embedding/tasks.md new file mode 100644 index 00000000000..f5dc7c2eec5 --- /dev/null +++ b/openspec/changes/archive/2026-06-11-dhis2-iframe-embedding/tasks.md @@ -0,0 +1,108 @@ +# Implementation Tasks — dhis2-iframe-embedding + +**Prerequisite:** `dhis2-oauth-spike` is archived. Validation artifacts are +referenced below. The silent-SSO phase builds on `dhis2-oauth-core` / v42 (this +change branches from `feature/dhis2-oauth`). + +## Phase 1 — CSP and forwarded-proto + +- [x] **1.1** Config key `openboxes.custom.iframe.frameAncestors` (list, default + `[]`) read from `openboxes.yml` external config — no upstream + `application.yml` edit. `server.use-forward-headers` documented in the + client template (Phase 3). +- [x] **1.2** `CspFrameAncestorsFilter` (`org.pih.warehouse.custom.iframe`) emits + `Content-Security-Policy: frame-ancestors 'self' ` and wraps the + response to suppress `X-Frame-Options`. **Gated:** no-op when the allow-list + is empty, so non-embedding deployments are byte-for-byte upstream. +- [x] **1.3** No in-repo `X-Frame-Options` source exists (repo grep), so no + upstream neutralization needed; the response wrapper strips any added later. +- [x] **1.4** Spock filter tests: empty allow-list → untouched response; + populated → directive includes each origin + wrapper applied; wrapper drops + `X-Frame-Options` on setHeader/addHeader. + +## Phase 2 — Cookie path (SameSite=None) via Rfc6265CookieProcessor + +> Revised from the original `Set-Cookie`-rewriting filter — see design D2. A +> header filter cannot reach Tomcat-serialised `JSESSIONID`; the cookie processor +> can. + +- [x] **2.1** `IframeCookieCustomizer` (`EmbeddedServletContainerCustomizer`) + installs `Rfc6265CookieProcessor(sameSiteCookies=None)` on OB's embedded + Tomcat 8.5.88 when embedding is enabled. No Tomcat upgrade, no DHIS2 change. +- [x] **2.2** Scoped + guarded: no-op when `frameAncestors` empty (keeps OB's + default cookie processor); logs a loud WARN that embedding requires TLS / + forwarded-proto (SameSite=None needs Secure or login breaks). +- [x] **2.3** Spock tests: registers one context customizer when enabled; none + when disabled; no-op for a non-Tomcat container. + +## Phase 3 — Forwarded-proto + +- [x] **3.1** `server.use-forward-headers=true` documented in the client template + (set via `openboxes.yml`, only for TLS-proxied embedding deployments). No + upstream `application.yml` edit. Provides the `Secure` flag that + `SameSite=None` requires. +- [x] **3.2** Live check (folded into 5.7): confirm `request.isSecure()` and a + `Secure` session cookie behind the dev-stack nginx. (Spring Boot built-in; + not unit-testable without the container.) + +## Phase 4 — Local dev stack + +- [x] **4.1** `docker/dhis2-sso/docker-compose.yml` — nginx (TLS) + openboxes + + mysql; DHIS2 treated as external (`DHIS2_UPSTREAM`). +- [x] **4.2** `docker/dhis2-sso/nginx.conf` — TLS server blocks for + `openboxes.localtest.me` / `dhis2.localtest.me`, forwarded-proto headers, + WebSocket upgrade, mounted certs. +- [x] **4.3** `docker/dhis2-sso/README.md` — mkcert, certs, OB config, DHIS2 + OAuth client setup (incl. `require-authorization-consent=false`), + troubleshooting. +- [x] **4.4** `docker/dhis2-sso/certs/.gitignore` — ignore all but `.gitignore`. +- [x] **4.5** Superseded: the `dhis2-sso` compose stack itself was **not** run. + The live embedded flow was validated more thoroughly via the EyeSeeTea + skeleton **installed as a real DHIS2 app** + a local d2-docker DHIS2 + (`2.42.4.1`) behind a TLS proxy — see `validation/cross-site-cookies.md`. + Boot-wiring bug found and fixed during this: iframe beans needed explicit + `grailsApplication` ref in `resources.groovy`, else NPE at container + creation aborted boot. +- [x] **4.6** No spike compose file exists to delete (n/a). + +## Phase 5 — Embedded silent SSO (v42) + v40 fallback + +- [x] **5.1** Optional `silent` arg on `Dhis2OAuthService.prepareAuthorize` / + `buildAuthorizeUrl` → appends `&prompt=none` for v42 only; + `isSilentAuthSupported()` exposes v42-only support. +- [x] **5.2** `Dhis2OAuthController.initiate`: embedded entry + (`?embedded=true`) + embedding enabled + v42 → silent `prompt=none`; + stores a one-shot `session.dhis2OAuthSilent` guard. +- [x] **5.3** `callback`: on `login_required`/`consent_required`/`interaction_required` + from a silent attempt → renders `breakout.gsp` which navigates + `window.top` to interactive login (no `prompt=none`, so no loop). +- [x] **5.4** v40: no silent attempt (profile guard); in-frame login works under + the CSP + SameSite=None cookie. +- [x] **5.5** Spock tests: embedded v42 → `prompt=none`; embedding off → no + silent; callback breakout vs login redirect; loop-guard cleared; v40 → + no `prompt=none`. +- [x] **5.6** Consent prerequisite documented in `docker/dhis2-sso/README.md`, + citing `validation/prompt-none.md`. +- [x] **5.7** **LIVE (manual):** prompt=none three-case (`validation/prompt-none.md`, + DHIS2 2.42.4.1) + full embedded browser run via the EyeSeeTea app skeleton + (`validation/cross-site-cookies.md`). Confirmed live: OB emits + `SameSite=None; Secure` + CSP `frame-ancestors`; silent `prompt=none` → + `login_required` → `breakout.gsp` → top-level interactive login → OB session. + In-frame *silent success* was subsequently **reproduced end-to-end** once + the embedder was the DHIS2 origin itself (skeleton installed as a DHIS2 + app + local DHIS2 `2.42.4.1`): OB rendered in-frame with no second login, + for admin and a fresh user. Confirmed the real topology makes DHIS2's own + cookie first-party (SameSite is a non-issue); only OB's cookie is + cross-site. See `validation/cross-site-cookies.md` (2026-06-11 update). + +## Phase 6 — Tests, docs, archive + +- [x] **6.1** Full suite green: `./gradlew test` — 973 tests, 9 skipped, 0 + failures, 0 errors (2026-06-11, JDK 8). +- [ ] **6.2** Frontend untouched (0 `src/js` changes). Local `npm test` blocked + by a Node-18-vs-floor-14 Babel toolchain mismatch (env, not code) — no + regression possible; verifying via CI (Node 14) on the PR. +- [x] **6.3** n/a — feature is off by default; top-level `README.md` unchanged. +- [ ] **6.4** Update PR description to reflect final shape. +- [ ] **6.5** OpenSpec archive: `design.md` upstream touch points + (`resources.groovy` only) + Deploy status; run `/opsx:archive`. diff --git a/openspec/changes/archive/2026-06-11-dhis2-iframe-embedding/validation/cross-site-cookies.md b/openspec/changes/archive/2026-06-11-dhis2-iframe-embedding/validation/cross-site-cookies.md new file mode 100644 index 00000000000..79e3b5dcbc6 --- /dev/null +++ b/openspec/changes/archive/2026-06-11-dhis2-iframe-embedding/validation/cross-site-cookies.md @@ -0,0 +1,145 @@ +# Validation — cross-site cookies, frame-ability, and break-out (live) + +Browser-level findings from running the full embedded flow locally: the +EyeSeeTea app skeleton (Vite, `http://localhost:8081`) embedding OB +(`http://localhost:8080`) in an iframe, against real DHIS2 instances. These +findings pin down the **single hardest constraint** of the feature: the +deployment topology, dictated by the DHIS2 session cookie's `SameSite`. + +## TL;DR + +| | Effect | +|---|---| +| DHIS2 session cookie is `SameSite=Strict` (sp/cpr-test) | The DHIS2 session is **invisible to the OB iframe unless the embedding page is the same site as DHIS2**. No OB/OB-cookie flag can change this — it is DHIS2's cookie. | +| DHIS2 login page sets `frame-ancestors 'none'` + `X-Frame-Options: SAMEORIGIN` | DHIS2's login page **cannot be framed by anyone**, by design. A not-logged-in user hitting it inside the iframe gets "refused to connect". | +| v42 `prompt=none` → `login_required` → `breakout.gsp` | Confirmed working: silent failure escapes the iframe to a **top-level** interactive login, then lands in OB. This is the designed cross-site fallback. | + +**Conclusion:** the embedding page, OB, and DHIS2 must all sit under the **same +registrable domain** (e.g. `*.samaritanspurse.org`). Then the `Strict` DHIS2 +cookie is sent in-frame, silent SSO succeeds, and OB renders inside the iframe. +Cross-site (e.g. a `localhost` dev skeleton against remote DHIS2) **cannot** see +the DHIS2 session and will always fall back to break-out (v42) or dead-end on the +un-framable login page (v40). + +## Evidence + +### 1. DHIS2 session cookie is `SameSite=Strict` (sp / cpr-test, v40) + +``` +$ curl -sSI https://cpr-test.samaritanspurse.org/dhis-web-commons/security/login.action +Set-Cookie: JSESSIONID=...; Path=/; Secure; HttpOnly; SameSite=Strict +``` + +`SameSite=Strict` ⇒ the browser sends the cookie **only when the top-level page +is the same site as the cookie's host**. With the skeleton at `localhost:8081` +(a different site), the iframe's request to `…/oauth/authorize` carries **no** +DHIS2 session cookie → DHIS2 treats it as anonymous. Being logged into DHIS2 in +another tab does not help: the cookie cannot cross sites. + +This is stricter than OB's own cookie: OB uses `SameSite=None` which *can* work +cross-site if `Secure`; `Strict` has no such escape hatch. + +### 2. DHIS2 login page forbids framing + +``` +$ curl -sSI https://cpr-test.samaritanspurse.org/dhis-web-commons/security/login.action +Content-Security-Policy: frame-ancestors 'none'; +X-Frame-Options: SAMEORIGIN +``` + +Other DHIS2 endpoints on the same host *do* allow framing — the instance's CSP +whitelist includes the dev origins (`http://localhost:8081`..`8085`, etc.). Only +the **login page** is hard-locked to `'none'` (clickjacking protection on +credential entry). So an anonymous request reaching the login page inside the +iframe yields the browser's "refused to connect". Not changeable from OB; not to +be changed in DHIS2. + +### 3. Break-out confirmed live (v42, `172.16.0.99`) + +OB access logs for one cycle (skeleton iframe → OB): + +``` +/oauth/dhis2/initiate?embedded=true [user:null] silent (prompt=none) +/oauth/dhis2/callback?error=login_required&… [user:null] silent failed → breakout.gsp +/oauth/dhis2/initiate [user:null] interactive (top-level, no prompt=none) +/oauth/dhis2/callback?code=… → user:admin-dhis2 SUCCESS +/openboxes/ → "requires location, redirecting to chooseLocation" +``` + +`breakout.gsp` runs `window.top.location.replace(initiateUrl)`, so the **whole +tab** leaves the skeleton and lands on OB at top level (`…/dashboard/chooseLocation`). +That top-level landing is the **correct** fallback, not a bug. The `login_required` +arose because the iframe could not see the DHIS2 session (cross-site, per §1). + +## Deployment requirement (both v40 and v42) + +- **Embedding app + OB + DHIS2 under one registrable domain** (any subdomains). + Subdomains are same-site, so the `Strict` DHIS2 cookie is sent in-frame. +- **OB over TLS** with `server.use-forward-headers: true` so its own + `SameSite=None` cookie is `Secure`. (Localhost-only crutch: + `server.session.cookie.secure: true` — never ship it.) +- **OB `frameAncestors`** lists the embedder's origin. +- **v42** degrades gracefully when there is no DHIS2 session (break-out to a + top-level login). **v40** has no `login_required` signal, so a not-logged-in + user hits the un-framable login page with no recovery — v40 effectively + requires an existing DHIS2 session. + +## Update (2026-06-11): in-frame silent success reproduced — production topology is "embedder is a DHIS2 app" + +The earlier sections assumed **topology B**: a standalone embedder app (the +`localhost` skeleton) that frames OB *and* reaches DHIS2. That topology makes +DHIS2's `SameSite=Strict`/`Lax` session cookie cross-site → invisible in-frame → +break-out every time. **Production is topology A**: the embedder is a **DHIS2 +app installed into DHIS2** and opened from within DHIS2, so the **top-level page +is the DHIS2 origin itself**. That changes everything: + +- DHIS2's own session cookie is now **first-party** to the top-level page → + sent in-frame regardless of `SameSite` (even `Strict`). The "DHIS2 cookie + can't cross sites" blocker **does not apply** to the real topology. +- The only cross-site party left is **OB** (the framed content), so only OB's + cookie needs `SameSite=None; Secure` — which it already sets. + +In-frame **silent success was reproduced end-to-end** on a local same-site rig: +DHIS2 `2.42.4.1` (d2-docker) behind a TLS proxy at `dhis2.tjk.test`, the +EyeSeeTea skeleton **built and installed as a real DHIS2 app**, embedding OB at +`https://openboxes.lvh.me/...initiate?embedded=true`. Logged into DHIS2, opening +the app rendered OB inside the iframe with **no second login** — for the admin +and for a fresh non-admin user. + +### Production config requirements discovered (DHIS2 side) + +- **`oauth2.server.enabled=on` REQUIRES `server.base.url`** set to the absolute + HTTPS DHIS2 URL (the OIDC issuer). Without it DHIS2 **fails to start** + (`issuer must be a valid URL`). Normally already set on a real instance. +- **`require-authorization-consent` must be `false`** on the OAuth client. + `prompt=none` cannot render a consent screen, so a consent-required client + fails silent SSO with `consent_required` until the user consents once. Set via + a full `clientSettings` replace (json-patch can't target a key inside the + serialized map). +- **Redirect URI host must have a real TLD.** DHIS2 rejects `.test`/`.local` + (`E1004 Invalid redirect URI`). Use `localhost`, an IP, or a real TLD. +- **DHIS2 2.42 needs Tomcat 10 / Jakarta.** The d2-docker `dhis2-core:2.42` tag + ships Tomcat 9 and silently fails to deploy the Jakarta WAR (blank app, no + `dhis.log`). Pin a Tomcat-10 core (`-c …/dhis2-core:2.42.4.1`). + +### Third-party-cookie limitation (the real-world caveat) + +With OB cross-site to DHIS2, OB's `SameSite=None` cookie is a **third-party** +cookie in the frame. When the browser blocks third-party cookies (Incognito, +Safari ITP, Chrome's deprecation), OB's session cookie is dropped on the OAuth +callback → OB can't find its stored `state` → **`Bad request: state mismatch`** +(reproduced in Incognito; the silent SSO itself succeeded — DHIS2 returned a +code — but the OB session was gone). Fixes: + +- **Deploy OB same-site with DHIS2** (a subdomain of the DHIS2 parent domain) → + not third-party → cookie always flows. *(Recommended; no code change.)* +- **or** mark OB's embedded session cookie **`Partitioned` (CHIPS)** — works in + partitioned third-party contexts. *(Code change in `IframeCookieCustomizer`.)* + +### Local-testing artifact (not a deployment concern) + +DHIS2's app shell is a PWA and registers a service worker on the DHIS2 origin. +Repointing the DHIS2 hostname to a different instance mid-test leaves a **stale +service worker** that serves cached assets as `text/html` → blank apps. Clear it +(DevTools → Application → Unregister + Clear site data). Production DHIS2 is not +swapped under a stable hostname, so this does not occur there. diff --git a/openspec/changes/archive/2026-06-11-dhis2-iframe-embedding/validation/prompt-none.md b/openspec/changes/archive/2026-06-11-dhis2-iframe-embedding/validation/prompt-none.md new file mode 100644 index 00000000000..c0fced5886e --- /dev/null +++ b/openspec/changes/archive/2026-06-11-dhis2-iframe-embedding/validation/prompt-none.md @@ -0,0 +1,76 @@ +# Validation — `prompt=none` (silent SSO) support in DHIS2 + +Source-level investigation of whether DHIS2's authorize endpoint honors the +OIDC `prompt=none` parameter, which is the mechanism for zero-click SSO when OB +is embedded in a DHIS2 iframe and the user already has a live DHIS2 session. + +## Verdict + +| | v42 (Spring Authorization Server) | v40 (legacy UAA) | +|---|---|---| +| `prompt=none` supported | **Yes** | **No** | +| Session + consented → silent `code` | Yes (immediate 302 with `?code=`) | No silent path | +| No session → `login_required` | Yes (OIDC error redirect) | No — renders login page, ignores param | +| Consent blocker | DHIS2 defaults `requireAuthorizationConsent=true` | No DHIS2-exposed auto-approve knob | + +**Conclusion:** zero-click iframe SSO is feasible on **v42 only**. v40 deployments +must fall back to log-in-once-in-frame (the `SameSite=None` session cookie then +keeps the user logged in on later loads). + +## v42 — Spring Authorization Server + +- DHIS2 2.42 ships `spring-authorization-server 1.5.5` (`dhis-2/pom.xml`). OIDC + `prompt=none` support landed in SAS 1.4.0 GA (2024-11-19), so it is present. +- Behavior is library-provided and spec-compliant + (`OAuth2AuthorizationCodeRequestAuthenticationProvider`, tag 1.5.5): + - not authenticated + `prompt=none` → `login_required` + - consent required + `prompt=none` → `consent_required` + - consent skipped when client `requireAuthorizationConsent=false` +- DHIS2 does **not** customize the prompt logic (`AuthorizationServerConfig`); + it uses the stock SAS endpoint. +- **Gotcha:** `Dhis2OAuth2ClientServiceImpl` defaults new clients to + `requireAuthorizationConsent(true)`. For true silent auth the OB client must + be registered with consent disabled, or the user must consent once (DHIS2 + persists consent). +- Requires `scope=openid` (prompt validator only engages for OIDC requests) — + the v42 profile already sends `openid username`. + +## v40 — legacy `spring-security-oauth2` + +- DHIS2 ≤2.41 uses `spring-security-oauth2 2.5.2.RELEASE` (EOL). The legacy + `AuthorizationEndpoint` never implemented OIDC `prompt`. `prompt=none` is + silently ignored: no session → login page (not `login_required`); no + DHIS2-exposed `autoApprove` on the `OAuth2Client` domain. + +## Live confirmation — CONFIRMED ✓ + +Tested 2026-06-11 against a live DHIS2 **2.42.4.1** instance (Spring +Authorization Server). Client `openboxes-dev`: `scopes=openid,username`, +`require-authorization-consent=true`, `require-proof-key=false`, +`redirectUris=http://localhost:8080/openboxes/oauth/dhis2/callback`. + +Authorize request: `response_type=code`, `prompt=none`, PKCE `S256`, +`scope=openid username`. + +| Case | Setup | Result (`Location`) | +|---|---|---| +| A — no session | unauthenticated request | `…/callback?error=login_required&error_description=OAuth 2.0 Parameter: prompt&state=test123` ✓ | +| C — live session | session via `POST /api/auth/login` | `…/callback?code=rJ-Pk_6W…&state=test123` — **silent code, no UI** ✓ | + +Notes: +- `prompt=none` is honored: no session yields the OIDC `login_required` error + redirect (not a rendered login page) — exactly what the break-out flow (D5) + keys off. +- With a live session the authorize endpoint returned an authorization `code` + with zero UI. Consent did not block because the admin user had already + consented for this client; a never-consented user with + `require-authorization-consent=true` would get `error=consent_required` until + consent is recorded or the client is set to `false`. +- HTTP Basic auth on the authorize request does NOT establish a SAS session + (returned `login_required`) — only a real login-session cookie counts. +- Token exchange was not exercised (plaintext client secret not on hand); the + silent `code` issuance is sufficient to confirm the flow. +- spring-security #18647 (a `prompt=none` regression on the **7.x** line) does + NOT manifest here — DHIS2 2.42.4.1 is on the 6.5.x / SAS 1.5.x line. + +**Verdict: silent SSO is viable on v42 — Phase 5 is safe to implement.** diff --git a/openspec/changes/dhis2-iframe-embedding/design.md b/openspec/changes/dhis2-iframe-embedding/design.md deleted file mode 100644 index 6158b9e779c..00000000000 --- a/openspec/changes/dhis2-iframe-embedding/design.md +++ /dev/null @@ -1,180 +0,0 @@ -## Context - -Embedding OB inside a DHIS2 dashboard requires a CSP `frame-ancestors` -directive that names specific third-party origins, plus a session cookie that -survives a cross-site request. - -Whether OB also emits a conflicting `X-Frame-Options` header today is -**determined by the spike** (`dhis2-oauth-spike` task 1.7 → -`validation/xframeoptions-source.txt`). A pre-audit grep of the repo found no -in-source `X-Frame-Options`, so the most likely outcome is: - -- **No in-repo source.** The header may come from a deploy-level proxy (nginx - in the dev stack, customer-side reverse proxy in production), or simply not - be set at all. Either way, the iframe filter just ADDS CSP — no upstream - code to neutralize. -- **Less likely**: a Grails plugin or Tomcat default is emitting it. The - spike artifact pins this down. If it's emitted, our filter strips it on - the way out (still no upstream-code edit needed — same filter, just an - extra response-header removal). - -The embedded Tomcat in Grails 3.3.16 / Spring Boot 1.5 is Tomcat 8.5.88 -(confirmed by `dhis2-oauth-spike` validation/tomcat-version.txt). -`Rfc6265CookieProcessor.sameSiteCookies` was backported to Tomcat 8.5 in -8.5.47 — it IS technically available at 8.5.88. However, the cookie path -remains a `Set-Cookie`-rewriting Servlet `Filter` (D2) because the filter -approach requires no Tomcat XML config changes and is easier to toggle at -runtime per the `iframe.frameAncestors` flag. The Rfc6265CookieProcessor -path is an available fallback if the filter proves problematic. - -Constraints: upstream-isolation (new code under `org.pih.warehouse.custom.*`, -surgical edits to upstream files), JDK 8 / Groovy 2.4 / Grails 3.3 floors. - -## Goals / Non-Goals - -**Goals:** -- OB pages embeddable in iframes whose top-level origin matches a configured - DHIS2 allow-list; safely default-deny when the list is empty. -- Session cookie `SameSite=None; Secure` when embedding is enabled. -- `Secure` cookies set correctly behind a TLS-terminating proxy - (`X-Forwarded-Proto: https`). -- Local TLS dev stack so developers can exercise the iframe flow against a - real DHIS2 instance without owning a public domain. - -**Non-Goals:** -- The OAuth/SSO flow itself (see `dhis2-oauth-core`). -- Deep-linking from DHIS2 dashboard items into OB screens. -- Iframe-aware re-auth handling when DHIS2 access tokens expire (out of v1 - scope across all three changes; mitigation: configure long-lived DHIS2 - tokens). - -## Decisions - -### D1: CSP `frame-ancestors`, not `X-Frame-Options` - -`X-Frame-Options: SAMEORIGIN` cannot allow specific third-party origins. -`Content-Security-Policy: frame-ancestors` can, and is the modern -replacement. We emit it from a custom filter that runs on all HTML responses, -configured via `iframe.frameAncestors` in `application.yml`. We also remove -any upstream-set `X-Frame-Options` header to avoid the two contradicting -each other. - -When the allow-list is empty (default), we emit `frame-ancestors 'self'` — -equivalent protection to `X-Frame-Options: SAMEORIGIN` for the browser, but -through a single header source the filter controls. - -### D2: `SameSite=None; Secure` via `Set-Cookie`-rewriting filter - -Spike validation/tomcat-version.txt confirms the embedded Tomcat is 8.5.88. -While `Rfc6265CookieProcessor.sameSiteCookies` is available on 8.5.88 -(backported in 8.5.47), the cookie path is a Servlet `Filter` because it -requires no Tomcat XML config changes and can be toggled at runtime via the -`iframe.frameAncestors` flag. The cookie path is therefore a Servlet `Filter` -that rewrites the `Set-Cookie` header on outgoing responses when -`iframe.frameAncestors` is non-empty. - -Filter behavior: -- Read all `Set-Cookie` headers on the response. -- For the session cookie (and any cookie not already declaring `SameSite`), - append `; SameSite=None; Secure` if not already present. -- Skip when `iframe.frameAncestors` is empty — preserves upstream behavior - for deployments not embedding OB. - -### D3: Forwarded-proto via Spring Boot's existing setting - -Spring Boot 1.5 supports `server.use-forward-headers=true` (verified in -`dhis2-oauth-spike` validation/spring-boot-1.5-forward-headers.txt), which -configures Tomcat's `RemoteIpValve`. Setting it makes `request.isSecure()` -reflect the proxy's `X-Forwarded-Proto` header. We enable it via a profile -or conditional only when iframe embedding is enabled, to avoid changing -behavior for deployments without a TLS-terminating proxy. - -### D4: Local dev stack as a separate `docker/dhis2-sso/` directory - -Avoid touching `docker/docker-compose.yml` (upstream file). New directory -contains: -- `docker-compose.yml` — services: `nginx`, `dhis2`, `dhis2-db` (postgres), - `openboxes`, `openboxes-db` (mysql). -- `nginx.conf` — two `server` blocks (443/TLS) proxying to internal services, - WebSocket upgrade headers, `X-Forwarded-Proto`, `X-Forwarded-For`, `Host`. -- `certs/.gitignore` — committed empty dir; certs generated by the developer. -- `README.md` — mkcert install, cert generation command, OAuth client - registration walkthrough in DHIS2 (referencing the spike's setup notes), - troubleshooting. - -Hostnames: `dhis2.localtest.me` and `openboxes.localtest.me`. `*.localtest.me` -is reserved by RFC 6761 / IANA and resolves to `127.0.0.1` automatically — -no `/etc/hosts` edits required for most resolvers (confirmed by -`dhis2-oauth-spike` validation/localtest-resolution.txt; corporate-DNS -workaround documented in the README). - -The compose file in this change supersedes the spike's -`docker-compose.spike.yml` (which only had DHIS2 + postgres). Once this -change lands, the spike's file can be deleted as part of `dhis2-oauth-spike` -archival. - -## Risks / Trade-offs - -- **`SameSite=None` cookie rewriting** depends on the `Set-Cookie` header - being unset before our filter runs. If upstream code commits the cookie - via a pre-baked `Set-Cookie` string mid-response (rare in Grails), the - filter would not be able to amend it. Mitigation: filter runs late in the - chain; if a real case shows up, document the operational workaround - ("front OB with nginx that rewrites `Set-Cookie`"). -- **Origin allow-list misconfiguration** silently breaks the iframe. The - browser shows the CSP violation in DevTools console. Mitigation: prominent - troubleshooting section in the dev stack README. -- **DHIS2 OAuth2 client setup varies by DHIS2 version**: the README pins the - DHIS2 image tag (same tag used in `dhis2-oauth-spike`). -- **Refresh tokens out of scope across all three changes**: in an iframe, - the re-auth bounce may break out of the iframe when tokens expire. - Mitigation: configure long-lived DHIS2 access tokens, or accept the - re-auth bounce in v1. Document in the README. - -## Migration Plan - -1. Land code with `iframe.frameAncestors = []` defaults — zero behavior - change for existing deployments. -2. No schema changes — no Liquibase work. -3. Per-deployment opt-in: set the allow-list of DHIS2 origins in - env-specific config and enable `server.use-forward-headers` if behind a - TLS-terminating proxy. -4. Rollback: clear `iframe.frameAncestors`. The CSP filter falls back to - `frame-ancestors 'self'` (browser-equivalent to the previous - `X-Frame-Options: SAMEORIGIN`), session cookie reverts to upstream - defaults. - -## Upstream touch points - -These upstream files will be edited; merge conflicts on future upstream -pulls should be expected here: - -- `grails-app/conf/application.yml` — add `iframe.*` and - `server.use-forward-headers` keys, with defaults that preserve upstream - behavior. -- **Conditional, pending spike task 1.7**: if the spike finds OB emits - `X-Frame-Options` from a source in-repo, neutralize it there so the new - CSP filter is the single source of truth. Pre-audit grep found no such - source — this touch point likely drops to zero. - -Everything else lives under the new custom package and `docker/dhis2-sso/`. - -## Confidence: 9/10 - -Re-scored after `dhis2-oauth-spike` validation artifacts landed. - -All assumptions confirmed: -- Tomcat 8.5.88 — filter-based cookie rewrite path confirmed (D2) ✓ -- No OB in-repo source of `X-Frame-Options` — conditional D1 touch-point - drops to zero for in-repo code ✓ -- `server.use-forward-headers=true` property name confirmed (D3) ✓ -- `*.localtest.me` resolves to 127.0.0.1 on this machine (D4) ✓ -- Config belongs in `docker/openboxes.yml`, not `application.yml` ✓ - -Note: DHIS2 itself already emits a `frame-ancestors` CSP on its own -responses (for the DHIS2 UI). This is DHIS2-side and does not affect OB's -filter, which controls OB's own response headers. - -Residual risk: the `Set-Cookie` rewrite filter cannot amend cookies committed -mid-response by upstream code (rare in Grails). Mitigation documented: front -OB with nginx that rewrites `Set-Cookie` as a last resort. diff --git a/openspec/changes/dhis2-iframe-embedding/proposal.md b/openspec/changes/dhis2-iframe-embedding/proposal.md deleted file mode 100644 index 3641ed3cd15..00000000000 --- a/openspec/changes/dhis2-iframe-embedding/proposal.md +++ /dev/null @@ -1,73 +0,0 @@ -## Why - -Operators deploying OpenBoxes alongside DHIS2 want to embed OB screens inside -DHIS2 dashboards/apps via iframe so users do not have to context-switch tabs. -Today OB serves `X-Frame-Options: SAMEORIGIN`, which blocks iframe embedding -from any other origin, and the session cookie is set without `SameSite=None`, -which would prevent it being sent on cross-site requests inside an iframe -even if the framing were allowed. - -This change owns the response-header, cookie, and forwarded-proto changes -needed to make iframe embedding work, plus a local TLS dev stack to test the -flow end-to-end without a public domain. It is independent of how users log -in — it makes embedding possible whether the user authenticates via DHIS2 -SSO (see `dhis2-oauth-core`) or local username/password. - -**This change depends on `dhis2-oauth-spike` being archived first** — -`design.md` Decisions reference its `validation/` artifacts, especially the -embedded-Tomcat version and `Rfc6265CookieProcessor.sameSiteCookies` -availability. - -`dhis2-oauth-core` is NOT a hard prerequisite for the code in this change. -The dev stack here pairs naturally with SSO for E2E testing, but the -header/cookie work stands on its own. - -## What Changes - -- Replace `X-Frame-Options` with `Content-Security-Policy: frame-ancestors` - scoped to a configured allow-list of DHIS2 origins (default empty → behaves - equivalently to `frame-ancestors 'self'`). -- Mark the session cookie `SameSite=None; Secure` when iframe embedding is - enabled (allow-list non-empty). Unchanged otherwise. -- Honor `X-Forwarded-Proto` so OB recognizes it is behind a TLS-terminating - proxy and sets `Secure` cookies correctly. -- Provide a local Docker dev stack under `docker/dhis2-sso/`: nginx - terminating TLS in front of `dhis2` and `openboxes` containers, mkcert-issued - certs, `*.localtest.me` hostnames so the OAuth + iframe flow can be tested - faithfully without a public domain. - -Out of scope: OAuth/SSO flow itself (see `dhis2-oauth-core`), deep-linking from -DHIS2 dashboard items into specific OB screens, iframe-aware re-auth handling -when access tokens expire. - -## Capabilities - -### New Capabilities -- `iframe-embedding`: CSP `frame-ancestors`, `SameSite=None; Secure` cookies, - forwarded-proto handling, configurable DHIS2 origin allow-list. -- `local-dev-tls`: docker-compose stack with nginx + mkcert + DHIS2 + OB for - faithful local OAuth/iframe testing. - -### Modified Capabilities - - -## Impact - -- **New custom backend code** under `org.pih.warehouse.custom.dhis2auth.iframe` - (or a sibling package — same custom isolation rules): CSP filter, cookie - rewrite filter (if needed per spike), forwarded-headers config. -- **Upstream touch points**: - - `grails-app/conf/application.yml` — config keys for - `iframe.frameAncestors` and forwarded-headers, with defaults that preserve - upstream behavior. - - The filter or interceptor that emits `X-Frame-Options` today (location - confirmed by `dhis2-oauth-spike` task 1.7) — replace with the - CSP-emitting filter, behind the allow-list flag. -- **New dev-environment files** under `docker/dhis2-sso/`: `docker-compose.yml`, - `nginx.conf`, `README.md`, `certs/.gitignore`. Does not modify the existing - `docker/docker-compose.yml`. -- **No new Grails plugins**. -- **No Liquibase changesets**. -- **Operational**: deployments embedding OB in DHIS2 must terminate TLS on a - domain reachable by both DHIS2 and the user's browser, and configure the - DHIS2 origin allow-list. Documented in the change's `design.md`. diff --git a/openspec/changes/dhis2-iframe-embedding/tasks.md b/openspec/changes/dhis2-iframe-embedding/tasks.md deleted file mode 100644 index 2d26386eda2..00000000000 --- a/openspec/changes/dhis2-iframe-embedding/tasks.md +++ /dev/null @@ -1,87 +0,0 @@ -# Implementation Tasks — dhis2-iframe-embedding - -**Prerequisite:** `dhis2-oauth-spike` is archived. Validation artifacts are -referenced below. `dhis2-oauth-core` is not a hard prerequisite — but pairing -the dev stack with SSO is what end-to-end testing looks like. - -## Phase 1 — CSP and forwarded-proto - -- [ ] **1.1** Config keys in `application.yml`: - `iframe.frameAncestors` (list of origins, default `[]`), - `server.use-forward-headers` (default unset / upstream behavior). -- [ ] **1.2** `Dhis2IframeFilter` (or named per design — same package - isolation rules) Groovy filter that, on every HTML response, sets - `Content-Security-Policy: frame-ancestors 'self' ` and - removes any `X-Frame-Options` header. When the allow-list is empty, - emit `frame-ancestors 'self'` so we remain the single source of truth - for framing. -- [ ] **1.3** **Conditional on spike outcome**: if - `dhis2-oauth-spike` validation/xframeoptions-source.txt identified an - in-repo source emitting `X-Frame-Options`, neutralize it (smallest - possible upstream edit). If the spike found no in-repo source, skip - this task and note in the PR description that the iframe filter is the - sole source. The filter itself already strips `X-Frame-Options` on the - way out as a belt-and-braces guard against deploy-time proxies adding - it back. -- [ ] **1.4** Spock filter tests asserting: - - allow-list empty → `frame-ancestors 'self'`, no `X-Frame-Options` - - allow-list populated → directive includes each configured origin - - upstream-set `X-Frame-Options` is removed in both cases - -## Phase 2 — Cookie path (SameSite=None; Secure) - -- [ ] **2.1** Per `dhis2-oauth-spike` validation/tomcat-version.txt, the - embedded Tomcat is 8.5 → use the `Set-Cookie`-rewriting filter (Plan B - from the original combined design). If the spike re-scored and found a - different Tomcat version, update Decision D2 and adjust this task. -- [ ] **2.2** `Set-Cookie` rewriter filter: - - active only when `iframe.frameAncestors` is non-empty, - - on the way out, appends `; SameSite=None; Secure` to cookies that do - not already declare `SameSite`, - - leaves `HttpOnly` and existing attributes intact. -- [ ] **2.3** Spock filter tests asserting: - - allow-list empty → cookie attributes unchanged - - allow-list non-empty + HTTPS → cookie has `SameSite=None; Secure` - - cookie that already declares `SameSite=Lax` is left untouched - -## Phase 3 — Forwarded-proto - -- [ ] **3.1** Enable `server.use-forward-headers=true` (Spring Boot 1.5 - property name confirmed via `dhis2-oauth-spike` task 1.10) under a - profile or conditional, only when iframe embedding is enabled. Do not - change behavior for deployments without a TLS-terminating proxy. -- [ ] **3.2** Spock integration test: simulate request with - `X-Forwarded-Proto: https` and confirm `request.isSecure()` returns - true and a `Secure` cookie is written. - -## Phase 4 — Local dev stack - -- [ ] **4.1** Flesh out `docker/dhis2-sso/docker-compose.yml` to include OB, - OB's MySQL, DHIS2, DHIS2's postgres, and nginx on the shared network. - Pin the DHIS2 image tag to match `dhis2-oauth-spike`. -- [ ] **4.2** `docker/dhis2-sso/nginx.conf` — two `server` blocks (TLS), - proxy headers (`X-Forwarded-Proto`, `X-Forwarded-For`, `Host`), - WebSocket upgrade, certs mounted from `./certs/`. -- [ ] **4.3** `docker/dhis2-sso/README.md` — mkcert install, cert - generation command, OAuth client setup walkthrough (link to - `dhis2-oauth-spike` validation/dhis2-oauth-client-setup.md), - troubleshooting (cookie issues, CSP errors, hostname resolution). -- [ ] **4.4** `docker/dhis2-sso/certs/.gitignore` — ignore everything except - `.gitignore` itself. -- [ ] **4.5** End-to-end manual run-through following the README from a - clean checkout. Capture any gotchas back into the README. -- [ ] **4.6** Delete `docker/dhis2-sso/docker-compose.spike.yml` (left over - from `dhis2-oauth-spike`) now that the full compose file supersedes it. - -## Phase 5 — Tests, docs, archive - -- [ ] **5.1** All Spock tests green: `./gradlew test`. -- [ ] **5.2** Frontend untouched. Run `npm test` to confirm no regressions. -- [ ] **5.3** Update top-level `README.md` only if user-facing behavior is - enabled by default (it isn't — skip unless we decide to enable it on a - specific customer branch). -- [ ] **5.4** Update PR description on the open PR (if any) to reflect final - shape. -- [ ] **5.5** OpenSpec archive: ensure `design.md` "Upstream touch points" - lists every modified upstream file; add "Deploy status" line; run - `/opsx:archive`. diff --git a/openspec/specs/dhis2-auth/spec.md b/openspec/specs/dhis2-auth/spec.md index cfdf7eb70c4..bf204020e33 100644 --- a/openspec/specs/dhis2-auth/spec.md +++ b/openspec/specs/dhis2-auth/spec.md @@ -1,5 +1,11 @@ -## ADDED Requirements - +# DHIS2 Authentication + +## Purpose +DHIS2 OAuth2 SSO for OpenBoxes: a login option, first-login auto-registration, +returning-user identity refresh, side-table user linkage, and pending-access +admin gating. Supports the `v40` (UAA OAuth 2.0) and `v42` (Spring Authorization +Server, OAuth 2.1 / OIDC) profiles. +## Requirements ### Requirement: DHIS2 OAuth login option The system SHALL allow users to authenticate via a DHIS2 OAuth2 Authorization Code flow when DHIS2 OAuth is configured. The existing username/password login @@ -55,8 +61,8 @@ gate, or the admin pending-users filter. - **THEN** OB rejects the callback with HTTP 400 and does not establish a session ### Requirement: First-login auto-registration -On the first successful DHIS2 OAuth login for a DHIS2 UID not previously seen, -the system SHALL create a new local OpenBoxes `User` with `active = false`, no +The system SHALL, on the first successful DHIS2 OAuth login for a DHIS2 UID not +previously seen, create a new local OpenBoxes `User` with `active = false`, no roles, and no location assignments, linked to the DHIS2 UID via a `Dhis2UserLink` record. @@ -73,10 +79,10 @@ roles, and no location assignments, linked to the DHIS2 UID via a - **THEN** all requests except logout and the pending-access page redirect to the pending-access page, which explains the user is awaiting access from an admin ### Requirement: Returning-user identity refresh -On every successful DHIS2 OAuth login for a DHIS2 UID that already has a -`Dhis2UserLink`, the system SHALL match the existing OB `User` by UID and -refresh non-authorization fields. Roles, location assignments, and the `active` -flag SHALL NOT be modified by login. +The system SHALL, on every successful DHIS2 OAuth login for a DHIS2 UID that +already has a `Dhis2UserLink`, match the existing OB `User` by UID and refresh +non-authorization fields. Roles, location assignments, and the `active` flag +SHALL NOT be modified by login. #### Scenario: Returning DHIS2 user with updated email - **WHEN** a returning DHIS2 user logs in and `/api/me` returns a different email or display name than stored @@ -102,3 +108,37 @@ The system SHALL allow administrators to filter the user admin screen by #### Scenario: Admin filters for pending users - **WHEN** an administrator selects the "Pending DHIS2 access" filter on the user admin list - **THEN** the list shows only users with `active = false` AND a non-null `Dhis2UserLink`, ordered by most recent `Dhis2UserLink.createdAt` first + +### Requirement: Embedded silent authentication +The system SHALL establish an OB session without user interaction when +OpenBoxes is served inside a DHIS2 iframe (embedding enabled via a non-empty +`iframe.frameAncestors`), an unauthenticated request arrives, and the +configured DHIS2 profile supports silent authentication (`v42`). Where the +profile does not support it (`v40`), the system SHALL fall back to in-frame +interactive login. The non-embedded (top-level) login flow SHALL remain +unchanged from the base DHIS2 OAuth behavior. + +#### Scenario: v42 silent success with a live DHIS2 session +- **WHEN** embedding is enabled, `custom.dhis2.oauth.profile = v42`, the request has no OB session, the user has a live DHIS2 session, and the OB OAuth client skips consent (or consent was previously granted) +- **THEN** OB redirects to the DHIS2 authorize endpoint with `prompt=none` (plus the usual PKCE challenge and `scope=openid username`), DHIS2 returns an authorization `code` with no UI, and OB establishes the session and renders the requested screen inside the frame + +#### Scenario: v42 no DHIS2 session breaks out for interactive login +- **WHEN** embedding is enabled, profile is `v42`, and the `prompt=none` authorize attempt returns `error=login_required` (or `interaction_required`) to the callback +- **THEN** OB does NOT render DHIS2's login page inside the frame; instead it returns a break-out response that navigates the top-level window (`window.top`) to the interactive authorize URL (no `prompt=none`) + +#### Scenario: v42 consent required is treated as a break-out +- **WHEN** the `prompt=none` attempt returns `error=consent_required` because the OB client was registered with `requireAuthorizationConsent=true` and the user has not yet consented +- **THEN** OB breaks out of the frame for the one-time interactive consent, after which subsequent `prompt=none` attempts succeed silently + +#### Scenario: silent attempt runs at most once (no redirect loop) +- **WHEN** a `prompt=none` attempt has already been made for the current navigation and returns an error again +- **THEN** OB does NOT issue another silent `prompt=none` redirect for that navigation (a one-shot guard prevents an authorize↔callback loop); it proceeds to the break-out interactive login + +#### Scenario: v40 falls back to in-frame login +- **WHEN** embedding is enabled and `custom.dhis2.oauth.profile = v40` (or unset) +- **THEN** OB makes NO silent `prompt=none` attempt; the unauthenticated user is shown the OB login (DHIS2 SSO button or local credentials) inside the frame, and after login the `SameSite=None; Secure` session cookie keeps the session active on later embedded loads + +#### Scenario: non-embedded login is unchanged +- **WHEN** `iframe.frameAncestors` is empty (embedding disabled), regardless of profile +- **THEN** the login flow is identical to the base DHIS2 OAuth behavior — no `prompt=none`, no break-out response + diff --git a/openspec/specs/iframe-embedding/spec.md b/openspec/specs/iframe-embedding/spec.md new file mode 100644 index 00000000000..6c19ec5ac7f --- /dev/null +++ b/openspec/specs/iframe-embedding/spec.md @@ -0,0 +1,48 @@ +# iframe-embedding Specification + +## Purpose +TBD - created by archiving change dhis2-iframe-embedding. Update Purpose after archive. +## Requirements +### Requirement: Configurable frame-ancestors allow-list +The system SHALL allow embedding OpenBoxes pages in an iframe whose top-level +origin matches a configured allow-list of DHIS2 origins, and SHALL deny +embedding from any other origin. + +#### Scenario: Allowed DHIS2 origin +- **WHEN** OB is configured with `iframe.frameAncestors = ["https://dhis2.example.org"]` and serves any HTML response +- **THEN** the response includes `Content-Security-Policy: frame-ancestors 'self' https://dhis2.example.org` and does NOT include an `X-Frame-Options` header + +#### Scenario: No allow-list configured (default) +- **WHEN** `iframe.frameAncestors` is empty or unset +- **THEN** OB serves `Content-Security-Policy: frame-ancestors 'self'` (no third-party embedding) — preserving upstream-equivalent protection + +#### Scenario: Multiple DHIS2 origins +- **WHEN** the allow-list contains multiple origins +- **THEN** all configured origins appear in the `frame-ancestors` directive, each as a complete origin (scheme + host + optional port), space-separated + +### Requirement: Cross-site session cookie +The system SHALL set the OpenBoxes session cookie with `SameSite=None; Secure` +when iframe embedding is enabled, so the cookie is sent with requests made from +within a DHIS2-embedded iframe. + +#### Scenario: Iframe embedding enabled +- **WHEN** `iframe.frameAncestors` is non-empty and a session is established over HTTPS +- **THEN** the `Set-Cookie` header for the session cookie includes `SameSite=None; Secure` and `HttpOnly` + +#### Scenario: Iframe embedding disabled (upstream behavior) +- **WHEN** `iframe.frameAncestors` is empty +- **THEN** the session cookie is set with the upstream default `SameSite` policy, unmodified + +### Requirement: Forwarded-proto handling +The system SHALL treat requests as HTTPS when a TLS-terminating proxy sets +`X-Forwarded-Proto: https`, so that the `Secure` cookie attribute is applied +correctly behind a reverse proxy. + +#### Scenario: Behind TLS-terminating proxy +- **WHEN** OB receives a request with `X-Forwarded-Proto: https` from a configured trusted proxy +- **THEN** `request.isSecure()` returns true and `Secure` cookies are set + +#### Scenario: Direct HTTP request (untrusted) +- **WHEN** OB receives an HTTP request with no forwarded headers (or from a non-trusted source) +- **THEN** `request.isSecure()` returns false and `Secure` cookies are NOT set + diff --git a/openspec/specs/local-dev-tls/spec.md b/openspec/specs/local-dev-tls/spec.md new file mode 100644 index 00000000000..cf8071aaf42 --- /dev/null +++ b/openspec/specs/local-dev-tls/spec.md @@ -0,0 +1,38 @@ +# local-dev-tls Specification + +## Purpose +TBD - created by archiving change dhis2-iframe-embedding. Update Purpose after archive. +## Requirements +### Requirement: Local docker-compose stack with TLS +The repository SHALL provide a docker-compose stack under `docker/dhis2-sso/` +that runs DHIS2, OpenBoxes, and an nginx TLS-terminating reverse proxy on a +shared Docker network, suitable for end-to-end testing of OAuth + iframe flows. + +#### Scenario: Single-command bring-up +- **WHEN** a developer runs `docker compose -f docker/dhis2-sso/docker-compose.yml up` from a clean checkout (after generating certs per README) +- **THEN** DHIS2, OpenBoxes, and nginx all start; nginx serves `https://dhis2.localtest.me` and `https://openboxes.localtest.me` with browser-trusted (mkcert-signed) TLS certs + +#### Scenario: Internal service-to-service traffic +- **WHEN** OpenBoxes performs the OAuth2 token exchange with DHIS2 +- **THEN** it uses the internal Docker network hostname (`http://dhis2:8080`), bypassing nginx + +#### Scenario: Browser-facing redirects use public hostnames +- **WHEN** OpenBoxes constructs the OAuth2 authorization URL or redirect URI +- **THEN** the URLs use `https://dhis2.localtest.me` and `https://openboxes.localtest.me` respectively, matching what is registered as the OAuth client in DHIS2 + +### Requirement: Documented certificate setup +The stack SHALL include a README documenting how to install mkcert, generate +certificates for the two hostnames, and place them where nginx mounts them. + +#### Scenario: Following README produces a working stack +- **WHEN** a developer follows the README from a clean machine (mkcert installed, no prior certs) +- **THEN** they end up with a stack where the browser shows a green padlock on both hostnames and `SameSite=None; Secure` cookies are honored + +### Requirement: No interference with existing dev stack +The new stack SHALL live in its own subdirectory and SHALL NOT modify the +existing `docker/docker-compose.yml` or its supporting files. + +#### Scenario: Existing dev stack untouched +- **WHEN** a developer not using DHIS2 SSO runs the existing `docker/docker-compose.yml` +- **THEN** it behaves identically to before this change (no new dependencies, no new required env vars) + diff --git a/src/main/groovy/org/pih/warehouse/custom/iframe/CspFrameAncestorsFilter.groovy b/src/main/groovy/org/pih/warehouse/custom/iframe/CspFrameAncestorsFilter.groovy new file mode 100644 index 00000000000..782104f16eb --- /dev/null +++ b/src/main/groovy/org/pih/warehouse/custom/iframe/CspFrameAncestorsFilter.groovy @@ -0,0 +1,49 @@ +package org.pih.warehouse.custom.iframe + +import grails.core.GrailsApplication + +import javax.servlet.Filter +import javax.servlet.FilterChain +import javax.servlet.FilterConfig +import javax.servlet.ServletRequest +import javax.servlet.ServletResponse +import javax.servlet.http.HttpServletResponse + +/** + * When iframe embedding is enabled (a non-empty DHIS2 origin allow-list), emits a + * Content-Security-Policy `frame-ancestors` directive scoped to that list and + * suppresses any X-Frame-Options so CSP is the single source of truth for framing. + * When embedding is disabled (the default), the filter is a pass-through and leaves + * OB responses exactly as upstream — no header is added. + */ +class CspFrameAncestorsFilter implements Filter { + + static final String CSP_HEADER = 'Content-Security-Policy' + static final String FRAME_ANCESTORS_SELF = "frame-ancestors 'self'" + + GrailsApplication grailsApplication + + void init(FilterConfig filterConfig) { } + + void destroy() { } + + void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) { + List origins = frameAncestors() + if (!origins) { + // Embedding disabled: do not touch the response — preserve upstream default behaviour. + chain.doFilter(request, response) + return + } + HttpServletResponse httpResponse = response as HttpServletResponse + httpResponse.setHeader(CSP_HEADER, frameAncestorsDirective(origins)) + chain.doFilter(request, new XFrameOptionsSuppressingResponse(httpResponse)) + } + + List frameAncestors() { + (grailsApplication.config.openboxes.custom.iframe.frameAncestors ?: []) as List + } + + String frameAncestorsDirective(List origins) { + origins ? "${FRAME_ANCESTORS_SELF} ${origins.join(' ')}" : FRAME_ANCESTORS_SELF + } +} diff --git a/src/main/groovy/org/pih/warehouse/custom/iframe/IframeCookieCustomizer.groovy b/src/main/groovy/org/pih/warehouse/custom/iframe/IframeCookieCustomizer.groovy new file mode 100644 index 00000000000..d6b8e9c25a7 --- /dev/null +++ b/src/main/groovy/org/pih/warehouse/custom/iframe/IframeCookieCustomizer.groovy @@ -0,0 +1,45 @@ +package org.pih.warehouse.custom.iframe + +import grails.core.GrailsApplication +import groovy.util.logging.Slf4j +import org.apache.tomcat.util.http.Rfc6265CookieProcessor +import org.springframework.boot.context.embedded.ConfigurableEmbeddedServletContainer +import org.springframework.boot.context.embedded.EmbeddedServletContainerCustomizer +import org.springframework.boot.context.embedded.tomcat.TomcatContextCustomizer +import org.springframework.boot.context.embedded.tomcat.TomcatEmbeddedServletContainerFactory + +/** + * When iframe embedding is enabled (a non-empty DHIS2 origin allow-list), installs + * Tomcat's Rfc6265CookieProcessor with SameSite=None on OB's own embedded Tomcat, + * so the session cookie is sent on requests made from inside a cross-site DHIS2 + * iframe. SameSite=None requires Secure, which OB sets once it knows it is behind + * TLS (server.use-forward-headers — see the forwarded-proto config). + * + * Scoped: when embedding is disabled (the default) this is a no-op and OB keeps its + * default cookie processor, so non-embedding deployments are unchanged. + */ +@Slf4j +class IframeCookieCustomizer implements EmbeddedServletContainerCustomizer { + + static final String SAME_SITE_NONE = 'None' + + GrailsApplication grailsApplication + + void customize(ConfigurableEmbeddedServletContainer container) { + if (!iframeEmbeddingEnabled || !(container instanceof TomcatEmbeddedServletContainerFactory)) { + return + } + log.warn("iframe embedding enabled: session cookie will be SameSite=None. " + + "OB MUST be served over TLS (set server.use-forward-headers=true behind a TLS-terminating " + + "proxy) or browsers will reject the cookie and login will fail.") + ((TomcatEmbeddedServletContainerFactory) container).addContextCustomizers({ context -> + Rfc6265CookieProcessor processor = new Rfc6265CookieProcessor() + processor.sameSiteCookies = SAME_SITE_NONE + context.cookieProcessor = processor + } as TomcatContextCustomizer) + } + + private boolean isIframeEmbeddingEnabled() { + ((grailsApplication.config.openboxes.custom.iframe.frameAncestors ?: []) as List) as boolean + } +} diff --git a/src/main/groovy/org/pih/warehouse/custom/iframe/XFrameOptionsSuppressingResponse.groovy b/src/main/groovy/org/pih/warehouse/custom/iframe/XFrameOptionsSuppressingResponse.groovy new file mode 100644 index 00000000000..584a6426c7c --- /dev/null +++ b/src/main/groovy/org/pih/warehouse/custom/iframe/XFrameOptionsSuppressingResponse.groovy @@ -0,0 +1,32 @@ +package org.pih.warehouse.custom.iframe + +import javax.servlet.http.HttpServletResponse +import javax.servlet.http.HttpServletResponseWrapper + +/** + * Drops any attempt to set `X-Frame-Options` on the wrapped response, so the + * CSP `frame-ancestors` directive emitted by {@link CspFrameAncestorsFilter} + * remains the single source of truth for framing policy. + */ +class XFrameOptionsSuppressingResponse extends HttpServletResponseWrapper { + + static final String X_FRAME_OPTIONS = 'X-Frame-Options' + + XFrameOptionsSuppressingResponse(HttpServletResponse response) { + super(response) + } + + @Override + void setHeader(String name, String value) { + if (!X_FRAME_OPTIONS.equalsIgnoreCase(name)) { + super.setHeader(name, value) + } + } + + @Override + void addHeader(String name, String value) { + if (!X_FRAME_OPTIONS.equalsIgnoreCase(name)) { + super.addHeader(name, value) + } + } +} diff --git a/src/test/groovy/org/pih/warehouse/custom/dhis2auth/Dhis2OAuthControllerSpec.groovy b/src/test/groovy/org/pih/warehouse/custom/dhis2auth/Dhis2OAuthControllerSpec.groovy index de16f6b1e7c..b43e6e81806 100644 --- a/src/test/groovy/org/pih/warehouse/custom/dhis2auth/Dhis2OAuthControllerSpec.groovy +++ b/src/test/groovy/org/pih/warehouse/custom/dhis2auth/Dhis2OAuthControllerSpec.groovy @@ -54,7 +54,7 @@ class Dhis2OAuthControllerSpec extends Specification void "initiate stores the PKCE verifier from prepareAuthorize in the session"() { given: enableOAuth('v42') - dhis2OAuthService.prepareAuthorize(_) >> new AuthorizeRequest( + dhis2OAuthService.prepareAuthorize(_, _) >> new AuthorizeRequest( url: 'https://dhis2.example.com/oauth2/authorize', codeVerifier: 'verifier-xyz') when: @@ -88,7 +88,7 @@ class Dhis2OAuthControllerSpec extends Specification void "initiate leaves the PKCE verifier null when prepareAuthorize returns none"() { given: enableOAuth('v40') - dhis2OAuthService.prepareAuthorize(_) >> new AuthorizeRequest( + dhis2OAuthService.prepareAuthorize(_, _) >> new AuthorizeRequest( url: 'https://dhis2.example.com/uaa/oauth/authorize', codeVerifier: null) when: @@ -98,11 +98,90 @@ class Dhis2OAuthControllerSpec extends Specification session.dhis2OAuthCodeVerifier == null } + void "embedded initiate on v42 attempts a silent prompt=none authorize"() { + given: + enableOAuth('v42') + enableIframe() + dhis2OAuthService.isSilentAuthSupported() >> true + dhis2OAuthService.prepareAuthorize(_, true) >> new AuthorizeRequest( + url: 'https://dhis2.example.com/oauth2/authorize?prompt=none', codeVerifier: 'verifier-xyz') + + when: + params.embedded = 'true' + controller.initiate() + + then: + session.dhis2OAuthSilent == true + session.dhis2OAuthCodeVerifier == 'verifier-xyz' + response.redirectedUrl == 'https://dhis2.example.com/oauth2/authorize?prompt=none' + } + + void "embedded initiate does not go silent when embedding is disabled"() { + given: + enableOAuth('v42') + dhis2OAuthService.prepareAuthorize(_, _) >> new AuthorizeRequest( + url: 'https://dhis2.example.com/oauth2/authorize', codeVerifier: 'v') + + when: + params.embedded = 'true' + controller.initiate() + + then: + session.dhis2OAuthSilent == false + response.redirectedUrl == 'https://dhis2.example.com/oauth2/authorize' + } + + void "callback breaks out of the iframe when a silent attempt returns login_required"() { + given: + enableOAuth('v42') + + when: + session.dhis2OAuthState = STATE + session.dhis2OAuthSilent = true + params.state = STATE + params.error = 'login_required' + controller.callback() + + then: + view == '/custom/dhis2auth/breakout' + session.dhis2OAuthSilent == null + 0 * dhis2OAuthService.exchangeCode(*_) + } + + void "callback redirects to login when an error arrives outside a silent attempt"() { + when: + session.dhis2OAuthState = STATE + params.state = STATE + params.error = 'access_denied' + controller.callback() + + then: + response.redirectedUrl == '/auth/login' + 0 * dhis2OAuthService.exchangeCode(*_) + } + + void "callback does not break out for login_required when the attempt was not silent"() { + when: + session.dhis2OAuthState = STATE + session.dhis2OAuthSilent = false + params.state = STATE + params.error = 'login_required' + controller.callback() + + then: "it redirects to login (a break-out would render the breakout view, not redirect)" + response.redirectedUrl == '/auth/login' + 0 * dhis2OAuthService.exchangeCode(*_) + } + private void enableOAuth(String profile) { controller.grailsApplication.config.openboxes.custom.dhis2.oauth.enabled = true controller.grailsApplication.config.openboxes.custom.dhis2.oauth.profile = profile } + private void enableIframe() { + controller.grailsApplication.config.openboxes.custom.iframe.frameAncestors = ['https://dhis2.example.com'] + } + void "callback for an active user establishes a session and redirects to the dashboard"() { given: User user = new User(username: 'alice', active: true) diff --git a/src/test/groovy/org/pih/warehouse/custom/dhis2auth/Dhis2OAuthServiceSpec.groovy b/src/test/groovy/org/pih/warehouse/custom/dhis2auth/Dhis2OAuthServiceSpec.groovy index b37a6a76732..b8ac667fc44 100644 --- a/src/test/groovy/org/pih/warehouse/custom/dhis2auth/Dhis2OAuthServiceSpec.groovy +++ b/src/test/groovy/org/pih/warehouse/custom/dhis2auth/Dhis2OAuthServiceSpec.groovy @@ -87,12 +87,49 @@ class Dhis2OAuthServiceSpec extends Specification implements ServiceUnitTest ancestors) { + config.openboxes.custom.iframe.frameAncestors = ancestors + new CspFrameAncestorsFilter(grailsApplication: grailsApplication) + } + + void "embedding disabled (empty allow-list) leaves the response untouched"() { + when: + filterWithAncestors([]).doFilter(request, response, chain) + + then: + 0 * response.setHeader(CSP, _) + 1 * chain.doFilter(request, response) + } + + void "configured origins emit the directive and wrap the response"() { + when: + filterWithAncestors(['https://dhis2.example.org', 'https://dhis2.other.org']) + .doFilter(request, response, chain) + + then: + 1 * response.setHeader(CSP, "frame-ancestors 'self' https://dhis2.example.org https://dhis2.other.org") + 1 * chain.doFilter(request, { it instanceof XFrameOptionsSuppressingResponse }) + } + + void "directive builder reflects the configured allow-list"() { + expect: + filterWithAncestors(ancestors).frameAncestorsDirective(ancestors) == expected + + where: + ancestors || expected + ['https://dhis2.example.org'] || "frame-ancestors 'self' https://dhis2.example.org" + } +} diff --git a/src/test/groovy/org/pih/warehouse/custom/iframe/IframeCookieCustomizerSpec.groovy b/src/test/groovy/org/pih/warehouse/custom/iframe/IframeCookieCustomizerSpec.groovy new file mode 100644 index 00000000000..50bfba2a396 --- /dev/null +++ b/src/test/groovy/org/pih/warehouse/custom/iframe/IframeCookieCustomizerSpec.groovy @@ -0,0 +1,55 @@ +package org.pih.warehouse.custom.iframe + +import org.apache.catalina.core.StandardContext +import org.apache.tomcat.util.http.Rfc6265CookieProcessor +import org.grails.testing.GrailsUnitTest +import org.springframework.boot.context.embedded.ConfigurableEmbeddedServletContainer +import org.springframework.boot.context.embedded.tomcat.TomcatContextCustomizer +import org.springframework.boot.context.embedded.tomcat.TomcatEmbeddedServletContainerFactory + +import spock.lang.Specification + +class IframeCookieCustomizerSpec extends Specification implements GrailsUnitTest { + + private IframeCookieCustomizer customizerWithAncestors(List ancestors) { + config.openboxes.custom.iframe.frameAncestors = ancestors + new IframeCookieCustomizer(grailsApplication: grailsApplication) + } + + void "installs an Rfc6265CookieProcessor with SameSite=None when embedding is enabled"() { + given: + TomcatEmbeddedServletContainerFactory factory = new TomcatEmbeddedServletContainerFactory() + StandardContext context = new StandardContext() + + when: + customizerWithAncestors(['https://dhis2.example.org']).customize(factory) + factory.tomcatContextCustomizers.each { TomcatContextCustomizer it -> it.customize(context) } + + then: + factory.tomcatContextCustomizers.size() == 1 + context.cookieProcessor instanceof Rfc6265CookieProcessor + ((Rfc6265CookieProcessor) context.cookieProcessor).sameSiteCookies.value == 'None' + } + + void "adds no customizer when embedding is disabled"() { + given: + TomcatEmbeddedServletContainerFactory factory = new TomcatEmbeddedServletContainerFactory() + + when: + customizerWithAncestors([]).customize(factory) + + then: + factory.tomcatContextCustomizers.isEmpty() + } + + void "is a no-op for a non-Tomcat container"() { + given: + ConfigurableEmbeddedServletContainer container = Mock() + + when: + customizerWithAncestors(['https://dhis2.example.org']).customize(container) + + then: + 0 * container._ + } +} diff --git a/src/test/groovy/org/pih/warehouse/custom/iframe/XFrameOptionsSuppressingResponseSpec.groovy b/src/test/groovy/org/pih/warehouse/custom/iframe/XFrameOptionsSuppressingResponseSpec.groovy new file mode 100644 index 00000000000..af4f79fc295 --- /dev/null +++ b/src/test/groovy/org/pih/warehouse/custom/iframe/XFrameOptionsSuppressingResponseSpec.groovy @@ -0,0 +1,33 @@ +package org.pih.warehouse.custom.iframe + +import javax.servlet.http.HttpServletResponse + +import spock.lang.Specification + +class XFrameOptionsSuppressingResponseSpec extends Specification { + + HttpServletResponse delegate = Mock() + XFrameOptionsSuppressingResponse wrapper = new XFrameOptionsSuppressingResponse(delegate) + + void "setHeader drops X-Frame-Options (case-insensitive) and passes everything else through"() { + when: + wrapper.setHeader('X-Frame-Options', 'DENY') + wrapper.setHeader('x-frame-options', 'SAMEORIGIN') + wrapper.setHeader('Content-Type', 'text/html') + + then: + 0 * delegate.setHeader('X-Frame-Options', _) + 0 * delegate.setHeader('x-frame-options', _) + 1 * delegate.setHeader('Content-Type', 'text/html') + } + + void "addHeader drops X-Frame-Options and passes everything else through"() { + when: + wrapper.addHeader('X-Frame-Options', 'DENY') + wrapper.addHeader('Cache-Control', 'no-store') + + then: + 0 * delegate.addHeader('X-Frame-Options', _) + 1 * delegate.addHeader('Cache-Control', 'no-store') + } +}