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 */ @@ -71,14 +86,22 @@ public class OAuth2DynamicClientRegistrationController { @Autowired private OAuth2DcrService oAuth2DcrService; /** - * Enroll a new device client, create an initial access token (IAT) and then redirect to the - * specified redirect URI with the IAT and state as query parameters, similar to the OAuth2 - * '/authorize' endpoint. + * Mints an Initial Access Token (IAT) via {@link OAuth2DcrService#createIat(String)} and + * redirects the user-agent back to the caller-provided {@code redirectUri} with the IAT (and the + * opaque {@code state}) attached as query parameters. The redirect shape mirrors the OAuth2 + * {@code /authorize} endpoint so that a device client can pick the IAT up the same way it would + * pick up an authorization code. + * + *

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: + * + *

    + *
  1. If an inline JWKS string is present in {@code ClientSettings} under {@link + * #CLIENT_INLINE_JWKS}, parses it, extracts the first RSA key, and builds a {@link + * NimbusJwtDecoder} from that public key, honouring the client's configured token endpoint + * signing algorithm when present. + *
  2. Otherwise delegates to the default {@link JwtClientAssertionDecoderFactory}, which handles + * {@code jwks_uri}, {@code client_secret_jwt}, and other standard cases. + *
+ * + *

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 */ public class InlineJwksJwtClientAssertionDecoderFactory implements JwtDecoderFactory { + /** + * {@link org.springframework.security.oauth2.server.authorization.settings.ClientSettings} key + * under which the inline JWKS JSON is stored for a {@link RegisteredClient}. + */ public static final String CLIENT_INLINE_JWKS = "client.inline.jwks"; + private final JwtClientAssertionDecoderFactory delegate = new JwtClientAssertionDecoderFactory(); private Function> jwtValidatorFactory = JwtClientAssertionDecoderFactory.DEFAULT_JWT_VALIDATOR_FACTORY; + /** + * Set the factory used to build the {@link OAuth2TokenValidator} applied to each decoded client + * assertion. Both the inline-JWKS decoder and the delegate {@link + * JwtClientAssertionDecoderFactory} are kept in sync so validation is identical regardless of + * which decoder path a client uses. + * + * @param factory function producing a validator for a given {@link RegisteredClient} + */ public void setJwtValidatorFactory( Function> factory) { this.jwtValidatorFactory = factory; @@ -67,13 +116,17 @@ public void setJwtValidatorFactory( } /** - * Create a JwtDecoder for the given RegisteredClient. When the client has inline JWKS configured - * in its ClientSettings, use that to create an RSA public key JwtDecoder. Otherwise, delegate to - * default JwtClientAssertionDecoderFactory (which supports jwks_uri, client_secret, etc). + * Create a {@link JwtDecoder} for verifying {@code private_key_jwt} client assertions from the + * given {@link RegisteredClient}. If the client has an inline JWKS configured in its {@code + * ClientSettings} under {@link #CLIENT_INLINE_JWKS}, a {@link NimbusJwtDecoder} is built from the + * first RSA key in that set. Otherwise the call is delegated to {@link + * JwtClientAssertionDecoderFactory}, which supports {@code jwks_uri}, {@code client_secret_jwt}, + * and other standard sources. * - * @param client the RegisteredClient - * @return the JwtDecoder - * @throws IllegalStateException if the inline JWKS is invalid or does not contain an RSA key + * @param client the client whose assertion is being verified + * @return a decoder configured for this client's JWKS source + * @throws IllegalStateException if the inline JWKS is present but cannot be parsed, or does not + * contain an RSA key */ @Override public JwtDecoder createDecoder(RegisteredClient client) {