Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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.
*
* <p>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.
*
* <p>See RFC 7591: OAuth 2.0 Dynamic Client Registration Protocol, section 2.1.
*
Expand All @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>The IAT is then used to register a new client at the /connect/register (RFC 7591) endpoint of
* the authorization server.
* <p>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.
*
* <p>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 <msvanaes@dhis2.org>
*/
Expand All @@ -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.
*
* <p>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(
Expand Down Expand Up @@ -112,11 +135,11 @@ public void enroll(
}

/**
* Check if the current user is authorized based on user group membership.
*
* <p>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 =
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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:
*
* <ul>
* <li>A public {@code jwks_uri} that the Authorization Server fetches over HTTP, handled by the
* default {@link JwtClientAssertionDecoderFactory}.
* <li>An inline JWKS stored directly in the client's {@link
* org.springframework.security.oauth2.server.authorization.settings.ClientSettings} under the
* {@link #CLIENT_INLINE_JWKS} key, which is the extension this factory adds.
* </ul>
*
* <p>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.
*
* <p>For a given {@link RegisteredClient} this factory returns a {@link JwtDecoder} that:
*
* <ol>
* <li>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.
* <li>Otherwise delegates to the default {@link JwtClientAssertionDecoderFactory}, which handles
* {@code jwks_uri}, {@code client_secret_jwt}, and other standard cases.
* </ol>
*
* <p>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 <msvanaes@dhis2.org>
*/
public class InlineJwksJwtClientAssertionDecoderFactory
implements JwtDecoderFactory<RegisteredClient> {

/**
* {@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<RegisteredClient, OAuth2TokenValidator<Jwt>> 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<RegisteredClient, OAuth2TokenValidator<Jwt>> factory) {
this.jwtValidatorFactory = factory;
Expand All @@ -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) {
Expand Down
Loading