Connecting to DHIS2 — end-to-end¶
Everything you need to point dhis2-utils at a real DHIS2 instance. Pick an authentication method, follow its section, and you'll have a verified profile you can reuse from the CLI and MCP tools.
Which auth should I use?¶
| Method | Best for | Interactivity | Lifecycle | Trust model |
|---|---|---|---|---|
| PAT | Automation, CI, day-to-day dev | None after first mint | Long-lived, user-revocable | Static token — anyone with it is that user |
| Basic | Scripts against dev/play instances | None | Forever until password change | Password hits the wire on every request |
| OAuth2 / OIDC | Interactive use, humans-in-the-loop, short-lived access tokens | Browser popup on first login | Access token ~5 min, refresh token auto-rotates | Per-device, per-user, revocable server-side |
Short version: start with PAT. Switch to OAuth2/OIDC only when you need interactive login flows or you explicitly need short-lived access tokens with refresh. Use Basic only when you can't avoid it.
The code paths are orthogonal — every AuthProvider in dhis2w-client/auth/ implements the same headers() / refresh_if_needed() Protocol, so the rest of the client is identical regardless of what you pick. See Pluggable auth for the internals.
Option 1 — Personal Access Token (PAT)¶
How DHIS2 issues PATs¶
Every DHIS2 user can mint long-lived PATs on their own profile page (/dhis-web-user-profile). Each token is scoped to that user's permissions. You can optionally restrict a token to specific HTTP methods, IP ranges, or referrer URLs. They're revocable one-by-one without affecting the user's password. Under the hood they carry the prefix d2pat_.
PATs travel as Authorization: ApiToken <token> — not Bearer, not Basic.
Minting a PAT¶
Three paths:
-
DHIS2 UI. Log into your instance → profile menu → Personal Access Tokens → Add new. Copy the token immediately (DHIS2 never shows it again).
-
The seed script (local stack).
make dhis2-seedcreates six variations bound toadminand writes them toinfra/home/credentials/.env.auth:See Local DHIS2 setup § Seeded auth for what each variation does.
-
Playwright helper.
make -C infra patdrives the DHIS2 UI via Playwright to mint one token against any instance you can log into with username + password. Useful when you want a PAT for a remote server without clicking through the UI manually. See Playwright PAT helper.
Adding the PAT as a profile¶
dhis2 profile add local \
--url http://localhost:8080 \
--auth pat \
--token "$DHIS2_PAT" \
--default --verify
The --verify flag probes /api/system/info and /api/me immediately so you know the token works before saving. --default makes this the profile used when no --profile is specified. Omit either if you want separate steps.
Or edit ~/.config/dhis2/profiles.toml directly:
default = "local"
[profiles.local]
base_url = "http://localhost:8080"
auth = "pat"
token = "d2pat_XXXXX..."
Verifying¶
dhis2 profile verify local
# OK local http://localhost:8080 auth=pat version=2.42.4 user=admin 273 ms
Re-run any time. verify with no argument probes every profile you have.
Option 2 — Basic auth¶
Only for dev / play instances. DHIS2 accepts HTTP Basic on /api/*, so dhis2w-client just base64-encodes user:pass and attaches it as Authorization: Basic ... on every request.
Adding a basic profile¶
dhis2 profile add play \
--url https://play.im.dhis2.org/dev \
--auth basic \
--username system --password System123 \
--verify
Profile shape in profiles.toml:
[profiles.play]
base_url = "https://play.im.dhis2.org/dev"
auth = "basic"
username = "system"
password = "System123"
profiles.toml is chmod 0600, but the password still sits on disk in plaintext. Treat Basic profiles as disposable.
Option 3 — OAuth2 / OIDC¶
Interactive browser-based flow with PKCE. DHIS2 mints short-lived JWT access tokens (default TTL 5 min) plus a refresh token (TTL 1 hour). On subsequent API calls dhis2w-client reuses the cached access token until it's about to expire, then auto-refreshes without bothering you. If the refresh token itself expires or is revoked, the next call triggers a fresh browser login.
This is the richest option and the one with the most moving parts. It requires both server-side configuration (dhis.conf + an OAuth2 client registration + a user column) and client-side configuration (a profile).
Step 0 — The big picture¶
DHIS2 v2.42 ships three cooperating pieces that must all be turned on:
- Spring Authorization Server — mounts
/oauth2/authorize,/oauth2/token,/oauth2/jwks,/.well-known/openid-configuration. This is what mints JWTs. - OIDC-login filter chain — bridges DHIS2's own login form into Spring AS so
/oauth2/authorizeknows who you are. - JWT Bearer authentication on
/api/*— validates incomingAuthorization: Bearer <jwt>headers, looks the issuer up in a registry, maps the JWT subject to a DHIS2 user, and attaches the right permissions.
Each piece has its own dhis.conf keys, and getting any of them wrong produces a distinct failure mode. The Troubleshooting table below shows exactly which error points to which missing key.
Step 1 — Turn on Spring Authorization Server (dhis.conf)¶
Add these to infra/home/dhis.conf (or your production dhis.conf) and restart DHIS2:
# 1. Mount Spring AS endpoints. Without this: GET /oauth2/authorize returns 404.
oauth2.server.enabled = on
# 2. Issuer URL embedded in minted JWTs (the `iss` claim). Must be the URL
# clients actually reach DHIS2 at. Without this: tokens have an empty/wrong
# issuer and the API-side validator rejects them.
server.base.url = http://localhost:8080
# 3. Accept JWT Bearer tokens on /api/*. Without this: API calls with a minted
# access token return 401 even though the token is cryptographically valid.
oidc.jwt.token.authentication.enabled = on
# 4. Wire DHIS2's own login form as the user-authentication front-end of the AS.
# Without this: /oauth2/authorize returns 500 with
# "No AuthenticationProvider found for OAuth2AuthorizationCodeRequestAuthenticationToken"
oidc.oauth2.login.enabled = on
# 5. Register DHIS2's own AS as a "generic" OIDC provider so the API-side JWT
# validator can find it by issuer. Without this: API calls return
# 401 "Invalid issuer"
# even when the token is fine. All URIs must be spelled out — DHIS2's
# GenericOidcProviderConfigParser does NOT auto-discover them from the issuer.
oidc.provider.dhis2.client_id = dhis2-utils-local
oidc.provider.dhis2.client_secret = dhis2-utils-local-secret-do-not-use-in-prod
oidc.provider.dhis2.issuer_uri = http://localhost:8080
oidc.provider.dhis2.authorization_uri = http://localhost:8080/oauth2/authorize
oidc.provider.dhis2.token_uri = http://localhost:8080/oauth2/token
oidc.provider.dhis2.jwk_uri = http://localhost:8080/oauth2/jwks
oidc.provider.dhis2.user_info_uri = http://localhost:8080/userinfo
oidc.provider.dhis2.redirect_url = http://localhost:8765
oidc.provider.dhis2.scopes = ALL
oidc.provider.dhis2.mapping_claim = sub
No keystore config is needed — DHIS2 auto-generates one on first start because oauth2.server.jwt.keystore.generate-if-missing defaults to true.
After saving, restart:
make dhis2-down
make dhis2-run # foreground — wait for the DHIS2 log to stabilise, or watch http://localhost:8080/dhis-web-login
Step 2 — Smoke-check that the server is wired up¶
# should return 200 with JSON listing issuer + endpoints
curl -s http://localhost:8080/.well-known/openid-configuration | python3 -m json.tool | head
# should 302 -> /dhis-web-login/ (anonymous user redirected to sign in)
curl -sL -o /dev/null -w '%{http_code} -> %{url_effective}\n' \
-G 'http://localhost:8080/oauth2/authorize' \
--data-urlencode 'response_type=code' \
--data-urlencode 'client_id=dhis2-utils-local' \
--data-urlencode 'redirect_uri=http://localhost:8765' \
--data-urlencode 'scope=ALL' \
--data-urlencode 'state=probe' \
--data-urlencode 'code_challenge=abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqr' \
--data-urlencode 'code_challenge_method=S256'
If the discovery endpoint 404s or the authorize endpoint 500s, stop and fix dhis.conf before going further — no profile will work until these probes pass.
Step 3 — Register an OAuth2 client in DHIS2¶
DHIS2 stores OAuth2 clients under /api/oAuth2Clients. Three non-obvious requirements — each was a real 500/401 during bring-up:
clientSecretmust be BCrypt-hashed. DHIS2 wires aBCryptPasswordEncoderinto Spring AS's client-authentication filter, so plaintext secrets in theoauth2_client.client_secretcolumn always fail/oauth2/tokenwith401 invalid_client.clientSettingsandtokenSettingsmust be non-empty Jackson-serialized Spring AS JSON. Leaving them blank triggersIllegalArgumentException: settings cannot be emptyinsideDhis2OAuth2ClientServiceImpl.toObjectwhen the authorization endpoint tries to rebuild aRegisteredClient. The values below match exactly what DHIS2's built-in settings app (/apps/settings#/oauth2) writes when you create a client via its UI.- Only
ALLworks as ascopesvalue. DHIS2 has no fine-grained OAuth scopes; Spring AS'svalidateScopesrejects anything that contains whitespace (so"openid email ALL"fails), and the server only recognises the single pseudo-scopeALL.
The DHIS2 settings app at /apps/settings#/oauth2 will create a client with working clientSettings/tokenSettings defaults but does not expose scopes or clientAuthenticationMethods, so a UI-created client cannot complete the end-to-end flow without post-editing. Use the API.
Two paths:
Creates a client named dhis2-utils-local with a deterministic secret, registers it against the running DHIS2, and writes the credentials to infra/home/credentials/.env.auth. Internals live in infra/scripts/_seed_auth_oauth2.py — inspect that file if you want to see the exact payload before POSTing.
Works against any DHIS2 v2.42+ you can log into as a user with "Manage oAuth2 clients" authority (admin has this). No make required. The recipe below produces a client equivalent to the seeded one.
1. BCrypt-hash the client secret. One-liner using uv run so the bcrypt dep is resolved on demand without a permanent install:
HASHED_SECRET=$(uv run --with bcrypt python -c '
import bcrypt
plain = b"CHANGE_ME_TO_A_REAL_SECRET"
print(bcrypt.hashpw(plain, bcrypt.gensalt(rounds=10)).decode())')
echo "$HASHED_SECRET"
# $2b$10$... (60 chars starting with $2b$ or $2a$)
Keep the plaintext separately — that's what ends up in your profile's client_secret field. DHIS2 only ever sees the hash.
2. POST the client to DHIS2. Paste this into a terminal, replacing DHIS2_URL, ADMIN_USER, ADMIN_PASS, the plaintext inside the python3 -c, and $CLIENT_ID / redirect URI if you want something other than the defaults:
DHIS2_URL=http://localhost:8080
ADMIN_USER=admin
ADMIN_PASS=district
CLIENT_ID=my-oauth2-client
REDIRECT_URI=http://localhost:8765
curl -s -u "$ADMIN_USER:$ADMIN_PASS" \
-H 'Content-Type: application/json' \
-X POST "$DHIS2_URL/api/oAuth2Clients" \
-d @- <<EOF
{
"name": "$CLIENT_ID",
"clientId": "$CLIENT_ID",
"clientSecret": "$HASHED_SECRET",
"clientAuthenticationMethods": "client_secret_basic,client_secret_post",
"authorizationGrantTypes": "authorization_code,refresh_token",
"redirectUris": "$REDIRECT_URI",
"scopes": "ALL",
"clientSettings": "{\"@class\":\"java.util.Collections\$UnmodifiableMap\",\"settings.client.require-proof-key\":false,\"settings.client.require-authorization-consent\":true}",
"tokenSettings": "{\"@class\":\"java.util.Collections\$UnmodifiableMap\",\"settings.token.reuse-refresh-tokens\":true,\"settings.token.x509-certificate-bound-access-tokens\":false,\"settings.token.id-token-signature-algorithm\":[\"org.springframework.security.oauth2.jose.jws.SignatureAlgorithm\",\"RS256\"],\"settings.token.access-token-time-to-live\":[\"java.time.Duration\",300.000000000],\"settings.token.access-token-format\":{\"@class\":\"org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat\",\"value\":\"self-contained\"},\"settings.token.refresh-token-time-to-live\":[\"java.time.Duration\",3600.000000000],\"settings.token.authorization-code-time-to-live\":[\"java.time.Duration\",300.000000000],\"settings.token.device-code-time-to-live\":[\"java.time.Duration\",300.000000000]}"
}
EOF
Expect a JSON response with "httpStatus":"Created" and a newly-minted uid. Check with:
curl -s -u "$ADMIN_USER:$ADMIN_PASS" \
"$DHIS2_URL/api/oAuth2Clients?filter=clientId:eq:$CLIENT_ID&fields=clientId,scopes,authorizationGrantTypes"
3. Update an existing client (PUT). The same payload works with PUT /api/oAuth2Clients/<uid>:
UID=$(curl -s -u "$ADMIN_USER:$ADMIN_PASS" \
"$DHIS2_URL/api/oAuth2Clients?filter=clientId:eq:$CLIENT_ID&fields=id" \
| jq -r '.oAuth2Clients[0].id')
curl -s -u "$ADMIN_USER:$ADMIN_PASS" \
-H 'Content-Type: application/json' \
-X PUT "$DHIS2_URL/api/oAuth2Clients/$UID" \
-d @- <<EOF
{ ...same payload... }
EOF
The clientId field is the unique key — running POST with the same clientId twice fails with a uniqueness error, so use PUT once the client exists.
4. Also register the same client in dhis.conf. The oidc.provider.dhis2.* block from Step 1 references this clientId / clientSecret. The plaintext goes in dhis.conf (DHIS2 uses the plaintext secret at startup to build the internal OIDC client registration), while the BCrypt hash lives in the database (DHIS2 uses the hash at request time to verify Basic auth on /oauth2/token). If you changed CLIENT_ID or the plaintext above, update dhis.conf to match and restart.
Step 4 — Wire the admin user's openId¶
DHIS2's JWT validator (Dhis2JwtAuthenticationManagerResolver$DhisJwtAuthenticationProvider) takes the value of whichever claim you named in oidc.provider.dhis2.mapping_claim (we use sub), and looks it up in the userinfo.openid column via UserService.getUserByOpenId. If the column is empty, the token is rejected with:
even though the token itself is valid. The fix is to set the openId attribute on the DHIS2 user so it matches the JWT subject:
ADMIN_ID=$(curl -s -u admin:district 'http://localhost:8080/api/me?fields=id' | jq -r '.id')
curl -s -u admin:district -X PATCH "http://localhost:8080/api/users/$ADMIN_ID" \
-H "Content-Type: application/json-patch+json" \
-d '[{"op":"add","path":"/openId","value":"admin"}]'
make dhis2-seed does this automatically for the admin user after upserting the OAuth2 client, so on the local stack you don't need to touch it yourself. See Appendix: The openId user field for why this field exists.
Step 5 — Add an OAuth2 profile¶
After the seed has populated .env.auth, --from-env pulls the client credentials straight in:
set -a; source infra/home/credentials/.env.auth; set +a
dhis2 profile add local_oidc --auth oauth2 --from-env --default
The expanded form (for non-seeded instances):
dhis2 profile add local_oidc \
--url http://localhost:8080 \
--auth oauth2 \
--client-id dhis2-utils-local \
--client-secret dhis2-utils-local-secret-do-not-use-in-prod \
--scope ALL \
--redirect-uri http://localhost:8765 \
--default
Note: add does not open a browser. It just writes the profile. Unlike PAT/Basic, where the profile itself is usable as soon as it's saved, OAuth2 needs a separate interactive step to actually obtain an access token.
The profile ends up in profiles.toml as:
default = "local_oidc"
[profiles.local_oidc]
base_url = "http://localhost:8080"
auth = "oauth2"
client_id = "dhis2-utils-local"
client_secret = "dhis2-utils-local-secret-do-not-use-in-prod"
scope = "ALL"
redirect_uri = "http://localhost:8765"
Step 6 — Run the interactive login¶
What happens in order:
- The CLI runs a preflight
GET /.well-known/openid-configurationagainst the profile'sbase_url. If DHIS2 isn't running the AS, you get a clean error pointing at the missingoauth2.server.enabledconfig — no browser opens. - It generates a PKCE
code_verifier/code_challengepair and a CSRFstatenonce. - It starts a FastAPI + uvicorn receiver bound to the
redirect_urihost:port (default127.0.0.1:8765). The receiver is plain HTTP, no WebSockets, and handles exactly oneGET /request. - It opens your default browser to
http://localhost:8080/oauth2/authorize?...with the PKCE challenge. - DHIS2 detects you're anonymous and redirects to its login page (
/dhis-web-login/). You log in with your DHIS2 credentials — whatever the admin or a real user account is. This is DHIS2's own form, not anythingdhis2-utilsships. - After login DHIS2 returns you to
/oauth2/authorizewith a session cookie, Spring AS mints an authorization code, and redirects your browser back tohttp://localhost:8765/?code=...&state=.... - The FastAPI receiver captures the code, validates
state, and renders a styled "Authentication successful, you can close this tab" page. dhis2w-clientPOSTs the code to/oauth2/tokenwith the PKCE verifier to exchange for access + refresh tokens.- Tokens are persisted to
~/.config/dhis2/tokens.sqlite(global scope) or.dhis2/tokens.sqlite(project scope) under the keyprofile:<name>. - A final verification against
/api/system/info+/api/meprints the authenticated username, version, and latency.
Expected output:
opening browser for 'local_oidc' -> http://localhost:8080 ...
verified: version=2.42.4 user=admin (273 ms)
Step 7 — Verify¶
dhis2 profile verify local_oidc
# OK local_oidc http://localhost:8080 auth=oauth2 version=2.42.4 user=admin 273 ms
verify never opens a browser. If the cached access token is still valid it just uses it; if it's near expiry, it silently refreshes. If there are no cached tokens yet (you haven't run login), you get a clean error telling you to run login first — no surprise browser popup.
Troubleshooting¶
Every DHIS2-side failure we hit during OAuth2 bring-up, the error message you'll see, and the knob that fixes it:
| Symptom | Layer | Fix |
|---|---|---|
GET /oauth2/authorize returns 404 |
Spring AS not mounted | Add oauth2.server.enabled = on, restart |
GET /.well-known/openid-configuration returns 404 |
Same as above | Same as above |
GET /oauth2/authorize returns 500 with scope "..." contains invalid characters |
Client has whitespace in scopes |
scopes = "ALL" in the seed / client, not "openid email ALL" |
GET /oauth2/authorize returns 500 with settings cannot be empty |
Client's clientSettings / tokenSettings are blank |
Populate both with valid Jackson-serialized Spring AS JSON (see seed) |
GET /oauth2/authorize returns 500 with No AuthenticationProvider found for OAuth2AuthorizationCodeRequestAuthenticationToken |
OIDC login chain not wired to AS | Add oidc.oauth2.login.enabled = on, restart |
POST /oauth2/token returns 401 invalid_client |
Client secret in DB is not BCrypt-hashed | Re-seed with a BCrypt-hashed secret |
POST /oauth2/token returns 400 invalid_grant |
Authorization code expired (5 min TTL) or already used | Run dhis2 profile login <name> again — it's a one-shot code |
GET /api/system/info returns 401 Invalid issuer |
No OIDC provider registered for the token's iss claim |
Add the full oidc.provider.dhis2.* block (all URIs required), restart |
DHIS2 startup log: OIDC configuration for provider: 'dhis2' contains an invalid property: 'scope', did you mean 'scopes'? |
Typo in dhis.conf | Rename oidc.provider.dhis2.scope → oidc.provider.dhis2.scopes |
DHIS2 startup log: missing a required property: 'user_info_uri' (or authorization_uri, token_uri, jwk_uri) |
Incomplete generic OIDC provider config | Add all seven oidc.provider.dhis2.*_uri entries, not just issuer_uri |
GET /api/system/info returns 401 Found no matching DHIS2 user for the mapping claim: 'sub' |
User's openId column is empty |
PATCH /api/users/<uid> to set openId = <username>; make dhis2-seed does this automatically |
dhis2 profile verify hangs or browser pops up unexpectedly |
Cached tokens are missing | verify should never browse — check you're on a current dhis2-utils; otherwise run login first |
Appendix: the openId user field¶
DHIS2 has, confusingly, both an openid database column on userinfo and an OpenID Connect feature. They pre-date each other — the column was there long before OIDC was wired up — but today they meet in the OIDC JWT validator.
When oidc.jwt.token.authentication.enabled = on, DHIS2 intercepts every request to /api/* that carries an Authorization: Bearer <jwt> header and runs it through Dhis2JwtAuthenticationManagerResolver:
- Extract the JWT's
issclaim. - Find the
DhisOidcClientRegistrationwhoseissuer_urimatches — this is what theoidc.provider.<id>.*block creates. If none match, reject with"Invalid issuer". - Verify the JWT's signature using the registration's JWK set.
- Pull the claim named by the registration's
mapping_claim(for us:sub). This yields a string — typically the DHIS2 username for self-issued tokens, or an email address for Google/Azure/WSO2 tokens. - Call
UserService.getUserByOpenId(<that-string>)and look up auserinforow whoseopenidcolumn equals it. If none match, reject with"Found no matching DHIS2 user for the mapping claim". - Attach that user's permissions to the request and continue.
The openid column is essentially a pointer from an external identity (whatever your IdP calls a user) to a DHIS2 user. Multiple userinfo rows can't share the same openid value — there's a btree index on it, and the constraint is enforced at insert time.
For our self-issued-JWT case (oidc.provider.dhis2, minting tokens for DHIS2 users against DHIS2 itself), we set openId = <username> so the JWT's sub=admin maps cleanly to the admin user row. For a federated setup (Google as IdP, DHIS2 as OIDC client), you'd set openId = <user's Google account id> or <user's email> depending on what you put in mapping_claim.
Setting it programmatically¶
ADMIN_ID=$(curl -s -u admin:district 'http://localhost:8080/api/me?fields=id' | jq -r '.id')
curl -s -u admin:district -X PATCH "http://localhost:8080/api/users/$ADMIN_ID" \
-H "Content-Type: application/json-patch+json" \
-d '[{"op":"add","path":"/openId","value":"admin"}]'
Note: the REST API field is named openId (camelCase); the database column is openid (lowercase). Use the API name in PATCH bodies.
Setting it via the UI¶
User profile → Edit details → OpenID field. Save. Works for any user.
Setting it via the seed¶
make dhis2-seed runs ensure_user_openid_mapping(admin, "admin") after upserting the OAuth2 client. See infra/scripts/seed_auth.py. For other users you need to set openId yourself; the seed only handles the admin used to run it.
Related docs¶
- Pluggable auth — the
AuthProviderProtocol,OAuth2Authinternals,TokenStoredesign. - Profiles — how
profiles.tomlis discovered and resolved across scopes. - Local DHIS2 setup — running the Docker stack + what
make dhis2-seedwrites. - Playwright PAT helper — minting PATs against any DHIS2 by driving the UI.