Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
614b9c4
docs(dhis2-iframe): spec embedded silent SSO (v42) + v40 fallback
gqcorneby Jun 11, 2026
c98f397
docs(dhis2-iframe): confirm prompt=none silent SSO live on v42
gqcorneby Jun 11, 2026
427e975
feat(dhis2-iframe): CSP frame-ancestors filter (Phase 1)
gqcorneby Jun 11, 2026
dd0fca8
feat(dhis2-iframe): v42 silent SSO via prompt=none + frame break-out …
gqcorneby Jun 11, 2026
ef5a8af
feat(dhis2-iframe): gate CSP filter + SameSite=None cookie (Phase 1-2)
gqcorneby Jun 11, 2026
df2a1c3
feat(dhis2-iframe): forwarded-proto config + local TLS dev stack (Pha…
gqcorneby Jun 11, 2026
7e5a746
docs(dhis2-iframe): revise D1/D2 + tasks for gating and Rfc6265 cookie
gqcorneby Jun 11, 2026
54a1a70
fix(dhis2-iframe): address review findings on break-out page
gqcorneby Jun 11, 2026
6217d5d
fix(dhis2-iframe): inject grailsApplication into iframe beans
gqcorneby Jun 11, 2026
5b7486b
docs(dhis2-iframe): document frameAncestors in openboxes.yml reference
gqcorneby Jun 11, 2026
1d0e216
docs(dhis2-iframe): add use-forward-headers to openboxes.yml reference
gqcorneby Jun 11, 2026
3aad9c4
fix(dhis2-iframe): address code-review findings on oauth
gqcorneby Jun 11, 2026
dfd9af2
docs(dhis2-iframe): record in-frame SSO validation + config reqs
gqcorneby Jun 11, 2026
7e39b76
docs(dhis2-iframe): mark phase-6 gates (full suite green; FE env note)
gqcorneby Jun 11, 2026
e5fa16f
docs(dhis2-iframe): archive change + sync permanent specs
gqcorneby Jun 11, 2026
935fae1
fix(dhis2-iframe): restore @Transactional on Dhis2RegistrationService
gqcorneby Jun 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions docker/dhis2-sso/README.md
Original file line number Diff line number Diff line change
@@ -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: <inject-via-env> # 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`.
3 changes: 3 additions & 0 deletions docker/dhis2-sso/certs/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# mkcert-issued certs are generated per-developer β€” never commit them.
*
!.gitignore
51 changes: 51 additions & 0 deletions docker/dhis2-sso/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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:
48 changes: 48 additions & 0 deletions docker/dhis2-sso/nginx.conf
Original file line number Diff line number Diff line change
@@ -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};
}
}
}
21 changes: 16 additions & 5 deletions docker/openboxes.client-template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 21 additions & 1 deletion docker/openboxes.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
19 changes: 19 additions & 0 deletions grails-app/conf/spring/resources.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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')
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> SILENT_INTERACTION_ERRORS =
['login_required', 'consent_required', 'interaction_required']

GrailsApplication grailsApplication
Dhis2OAuthService dhis2OAuthService
Dhis2RegistrationService dhis2RegistrationService
Expand All @@ -25,29 +29,51 @@ 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)
}

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)
Expand Down Expand Up @@ -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
}
}
Loading