diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oauth2/dcr/OAuth2DcrService.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oauth2/dcr/OAuth2DcrService.java index 8e38731a3b8e..a9bd3c3aa058 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oauth2/dcr/OAuth2DcrService.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oauth2/dcr/OAuth2DcrService.java @@ -79,8 +79,17 @@ import org.springframework.transaction.annotation.Transactional; /** - * Service for managing OAuth2 Dynamic Client Registration (DCR) in DHIS2, including creation of - * initial access tokens (IAT) for enrolling new device clients. + * Bootstraps OAuth2 Dynamic Client Registration (DCR) support and mints the Initial Access Tokens + * (IATs) that enroll new device clients. + * + *
Active only when {@code oauth2.server.enabled=on} (see {@link + * AuthorizationServerEnabledCondition}). At startup {@link #init()} ensures the reserved {@code + * system-dcr-registrar-client} exists; that client is the sole holder of {@code client_credentials} + * in the deployment and is used internally to mint IATs for RFC 7591 dynamic client registration. + * + *
Flow: the user-facing {@code GET /api/auth/enrollDevice} endpoint calls {@link #createIat} to + * obtain a single-use IAT JWT, which the device then presents at {@code /connect/register} to + * register itself as a new OAuth2 client. * *
See RFC 7591: OAuth 2.0 Dynamic Client Registration Protocol, section 2.1. * @@ -101,6 +110,14 @@ public class OAuth2DcrService { private RegisteredClient registeredClient; private JwtEncoder jwtEncoder; + /** + * Initializes the Jackson mapper (preloaded with {@link SecurityJackson2Modules} and {@link + * OAuth2AuthorizationServerJackson2Module}) and the JWT encoder, then ensures the reserved {@code + * system-dcr-registrar-client} exists. The registrar is created with {@code client_credentials} + * grant, {@code ClientAuthenticationMethod.NONE}, scope {@code client.create}, and redirect URIs + * seeded from the {@code deviceEnrollmentRedirectAllowlist} system setting. If the client already + * exists the stored definition is reused as-is. + */ @PostConstruct void init() { this.jwtEncoder = new NimbusJwtEncoder(jwkSource); @@ -141,6 +158,14 @@ void init() { } } + /** + * Mint a single-use Initial Access Token (IAT) bound to the given redirect URI, persist the + * backing {@link OAuth2Authorization}, and return the JWT-encoded token alongside it. The TTL is + * read from the {@code deviceEnrollmentIATTtlSeconds} system setting. + * + * @param redirectUri the redirect URI to embed in the token's claims + * @return the persisted {@link OAuth2Authorization} and its signed JWT string + */ @Nonnull @Transactional public IatPair createIat(@Nonnull String redirectUri) { @@ -221,6 +246,7 @@ public static IatPair createIaToken( return new IatPair(authorization, jwtEncodedToken); } + /** Carrier for a minted IAT: the persisted {@link OAuth2Authorization} and the signed JWT. */ public record IatPair(OAuth2Authorization authorization, String iatJwt) {} /** diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/oauth/OAuth2DynamicClientRegistrationController.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/oauth/OAuth2DynamicClientRegistrationController.java index b247cbdd1cc6..aff3a9a9b94b 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/oauth/OAuth2DynamicClientRegistrationController.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/oauth/OAuth2DynamicClientRegistrationController.java @@ -50,12 +50,27 @@ import org.springframework.web.util.UriComponentsBuilder; /** - * Controller for enrolling devices using OAuth2 Dynamic Client Registration (DCR) (RFC 7591). A new - * device client can be enrolled by obtaining an initial access token (IAT) using the custom - * '/enroll' endpoint. + * REST controller for the DHIS2 device enrollment flow backing OAuth2 Dynamic Client Registration + * (DCR) as defined by RFC 7591. A new device client is enrolled in two steps: the user first hits + * {@code GET /api/auth/enrollDevice} to be redirected back to the device with a freshly minted + * Initial Access Token (IAT); the device then presents that IAT as a Bearer credential to the + * authorization server's {@code /connect/register} endpoint (provided by Spring Authorization + * Server) which persists a new {@link org.hisp.dhis.security.oauth2.client.Dhis2OAuth2Client} row + * configured with {@link + * org.springframework.security.oauth2.core.ClientAuthenticationMethod#PRIVATE_KEY_JWT} and returns + * the standard RFC 7591 registration response. * - *
The IAT is then used to register a new client at the /connect/register (RFC 7591) endpoint of - * the authorization server. + *
The DCR endpoint is implicitly enabled whenever {@code oauth2.server.enabled=on}, gated here + * by {@link AuthorizationServerEnabledCondition}. The registration payload typically ships an + * inline {@code jwks} (rather than a remote {@code jwks_uri}), because the main DCR client is the + * DHIS2 Android Capture app, whose per-device RSA keypair is generated in the Android Keystore and + * cannot be hosted at a public URL; the inline form is decoded at token-endpoint time by {@link + * org.hisp.dhis.webapi.security.config.InlineJwksJwtClientAssertionDecoderFactory}. IATs are + * single-use: once {@code /connect/register} consumes the underlying authorization, the IAT cannot + * be replayed. + * + *
The primary client of this flow is the DHIS2 Android Capture app; the {@code /enrollDevice}
+ * step below prepares the redirect that seeds it with an IAT.
*
* @author Morten Svanæs Gated by two system-settings allowlists: the caller must belong to one of the user groups in
+ * {@code deviceEnrollmentAllowedUserGroups} (HTTP 403 {@code forbidden_user} otherwise), and the
+ * {@code redirectUri} must match an entry in {@code deviceEnrollmentRedirectAllowlist} (HTTP 400
+ * {@code invalid_redirect_uri} otherwise). The response is marked non-cacheable before the
+ * redirect is issued.
*
- * @param redirectUri the redirect URI to send the IAT to
- * @param state an opaque value that will be returned to the client
- * @param response the HTTP response
- * @throws IOException if an I/O error occurs
+ * @param redirectUri the redirect URI to send the IAT to; must match the redirect allowlist
+ * @param state an opaque value that is echoed back to the client on the redirect
+ * @param response the HTTP response used to emit either the error or the redirect
+ * @throws IOException if writing the redirect or error response fails
*/
@GetMapping("/enrollDevice")
public void enroll(
@@ -112,11 +135,11 @@ public void enroll(
}
/**
- * Check if the current user is authorized based on user group membership.
- *
- * Default: allow all authenticated users regardless of group membership.
+ * Checks whether the current user is allowed to enroll a device based on user group membership,
+ * using the {@code deviceEnrollmentAllowedUserGroups} system setting as a comma-separated list of
+ * group ids. If the setting is blank, all authenticated users are allowed.
*
- * @return true if the user is authorized, false otherwise
+ * @return {@code true} if the caller may enroll a device, {@code false} otherwise
*/
private boolean hasValidUserGroupAuthorization() {
String allowedUserGroups =
@@ -138,10 +161,13 @@ private boolean hasValidUserGroupAuthorization() {
}
/**
- * Check if the provided redirect URI is allowed based on an allowlist from system settings.
+ * Checks whether the supplied redirect URI is permitted for device enrollment. Each non-blank
+ * entry in the {@code deviceEnrollmentRedirectAllowlist} system setting is converted to a regex
+ * via {@link TextUtils#createRegexFromGlob(String)} and matched case-insensitively against the
+ * candidate URI. A blank or missing allowlist rejects all redirect URIs.
*
- * @param redirectUri the redirect URI to check
- * @return true if the redirect URI is allowed, false otherwise
+ * @param redirectUri the redirect URI proposed by the caller
+ * @return {@code true} if the redirect URI matches the allowlist, {@code false} otherwise
*/
private boolean isRedirectUriAllowed(String redirectUri) {
if (redirectUri == null || redirectUri.isBlank()) return false;
diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/config/InlineJwksJwtClientAssertionDecoderFactory.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/config/InlineJwksJwtClientAssertionDecoderFactory.java
index 5abc633777be..d3d86cdcd8b2 100644
--- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/config/InlineJwksJwtClientAssertionDecoderFactory.java
+++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/config/InlineJwksJwtClientAssertionDecoderFactory.java
@@ -46,19 +46,68 @@
import org.springframework.util.StringUtils;
/**
- * JwtDecoderFactory for client assertions that uses inline JWKS (if present in ClientSettings)
+ * DHIS2-specific {@link JwtDecoderFactory} used by Spring Authorization Server to verify {@code
+ * private_key_jwt} client assertions (RFC 7523) at the {@code /oauth2/token} endpoint.
+ *
+ * In the {@code private_key_jwt} authentication method, an OAuth2 client authenticates to the
+ * token endpoint by signing a short-lived JWT (a "client assertion") with its private key instead
+ * of sending a shared {@code client_secret}. The Authorization Server verifies the assertion's
+ * signature against the client's public JWKS. Spring Authorization Server supports two JWKS sources
+ * for this:
+ *
+ * Inline JWKS support exists for clients that cannot host a public JWKS URL. The primary use
+ * case is the DHIS2 Android Capture app: each device generates its keypair inside the Android
+ * Keystore, registers with DHIS2 via Dynamic Client Registration (DCR) sending its public JWK
+ * inline in the registration payload, and from then on authenticates to the token endpoint with
+ * {@code private_key_jwt} signed by the Keystore-held private key.
+ *
+ * For a given {@link RegisteredClient} this factory returns a {@link JwtDecoder} that:
+ *
+ * The same {@link OAuth2TokenValidator} is applied to both paths so inline-JWKS clients get the
+ * same assertion validation rules (audience, issuer, expiry, and so on) as {@code jwks_uri}
+ * clients.
*
* @author Morten Svanæs
+ *
+ *
+ *
+ *
+ *
+ *