Skip to content
Closed
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 @@ -35,6 +35,11 @@
/**
* Shared helpers for Spring Authorization Server {@link AuthorizationGrantType} values.
*
* <p>Used by {@code Dhis2OAuth2ClientServiceImpl} when building a Spring AS {@link
* org.springframework.security.oauth2.server.authorization.client.RegisteredClient} from a
* persisted {@link org.hisp.dhis.security.oauth2.client.Dhis2OAuth2Client}, where the stored
* grant-type strings need to be resolved back to Spring's typed constants.
*
* @author Morten Svanæs <msvanaes@dhis2.org>
*/
public final class OAuth2GrantTypes {
Expand All @@ -44,7 +49,7 @@ private OAuth2GrantTypes() {}
/**
* Map a grant-type string back to Spring's canonical {@link AuthorizationGrantType} singleton
* (authorization_code, client_credentials, refresh_token, device_code). Falls back to a new
* instance for any custom value — the equality contract on {@code AuthorizationGrantType} is
* instance for any custom value. The equality contract on {@code AuthorizationGrantType} is
* value-based, but returning the singleton where possible keeps identity comparisons working.
*
* <p>Case labels are the RFC-defined grant-type strings (RFC 6749 + RFC 8628); they match
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,18 +38,42 @@
import org.hisp.dhis.common.DxfNamespaces;
import org.hisp.dhis.common.MetadataObject;

/**
* @author Morten Svanæs <msvanaes@dhis2.org>
*/
@Getter
@Setter
@JacksonXmlRootElement(localName = "oauth2Authorization", namespace = DxfNamespaces.DXF_2_0)
public class Dhis2OAuth2Authorization extends BaseIdentifiableObject implements MetadataObject {

Dhis2OAuth2Authorization() {}

/**
* Reference to the {@link org.hisp.dhis.security.oauth2.client.Dhis2OAuth2Client} this grant was
* issued to. Holds the internal id of the registered client, not its public {@code clientId}.
*/
@JsonProperty private String registeredClientId;

/**
* Name of the resource owner the grant is tied to. For user-delegated flows this is the DHIS2
* username; for {@code client_credentials} it is the client itself.
*/
@JsonProperty private String principalName;

/**
* The grant type that produced this authorization (e.g. {@code authorization_code}, {@code
* client_credentials}, {@code refresh_token}, {@code
* urn:ietf:params:oauth:grant-type:device_code}).
*/
@JsonProperty private String authorizationGrantType;

/** Comma-separated list of scopes that were actually granted for this authorization. */
@JsonProperty private String authorizedScopes;

/** JSON-encoded Spring AS attributes map (authenticated principal, request metadata, etc.). */
@JsonProperty private String attributes;

/** Opaque {@code state} value used by Spring AS for OAuth2 CSRF protection during the flow. */
@JsonProperty private String state;

@JsonProperty private String authorizationCodeValue;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,19 @@
import javax.annotation.Nonnull;
import org.hisp.dhis.common.IdentifiableObjectStore;

/** Store for OAuth2Authorization entities. */
/**
* Persistence store for {@link Dhis2OAuth2Authorization}. These lookup methods back Spring
* Authorization Server's {@link
* org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService}
* implementation, in particular its {@code findByToken(String, OAuth2TokenType)} contract.
*
* <p>{@link #getByToken(String)} has union semantics: it searches across every token column on
* {@link Dhis2OAuth2Authorization} (authorization code, access token, refresh token, OIDC ID token,
* user code, device code) and returns the first matching row, which is what Spring AS needs when
* asked to resolve a token without knowing its type up-front.
*
* @author Morten Svanæs <msvanaes@dhis2.org>
*/
public interface Dhis2OAuth2AuthorizationStore
extends IdentifiableObjectStore<Dhis2OAuth2Authorization> {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,26 @@
import org.hisp.dhis.common.DxfNamespaces;
import org.hisp.dhis.common.MetadataObject;

/**
* Persisted OAuth2 registered-client entity. Mirrors Spring Authorization Server's {@link
* org.springframework.security.oauth2.server.authorization.client.RegisteredClient}, mapped to the
* {@code oauth2_client} table, and is the DB row that authorizes an app to request tokens from
* DHIS2 acting as an Authorization Server.
*
* <p>Exposed via the admin CRUD endpoints under {@code /api/oAuth2Clients}, gated by the {@code
* F_OAUTH2_CLIENT_MANAGE} authority. Clients can also be created dynamically via the Dynamic Client
* Registration (RFC 7591) endpoint {@code /connect/register}.
*
* <p>Several fields are stored as comma-separated strings (for example {@link
* #authorizationGrantTypes}, {@link #clientAuthenticationMethods}, {@link #redirectUris}, {@link
* #postLogoutRedirectUris}, {@link #scopes}) and are parsed into Spring AS's typed values (e.g.
* {@link org.springframework.security.oauth2.core.AuthorizationGrantType}, {@link
* org.springframework.security.oauth2.core.ClientAuthenticationMethod}) by {@code
* Dhis2OAuth2ClientServiceImpl.toObject} when building a {@link
* org.springframework.security.oauth2.server.authorization.client.RegisteredClient}.
*
* @author Morten Svanæs <msvanaes@dhis2.org>
*/
@Getter
@Setter
@JacksonXmlRootElement(localName = "oauth2Client", namespace = DxfNamespaces.DXF_2_0)
Expand All @@ -49,7 +69,7 @@ public Dhis2OAuth2Client() {}
* Override so that the persisted {@code name} column is always populated even if the caller (the
* settings UI, which has no name field) doesn't supply one. Hibernate uses property access for
* this entity, so the value returned here is what gets written to the DB and what the schema
* validator at {@code POST /api/schemas/oAuth2Client} reads via reflection letting us keep
* validator at {@code POST /api/schemas/oAuth2Client} reads via reflection, letting us keep
* {@code not-null="true"} on the column without breaking UI pre-validation. Truncated to the
* column length (230) so the schema-validator's {@code @PropertyRange} check on a long {@code
* clientId} (max 255) doesn't reject the request.
Expand All @@ -75,15 +95,65 @@ public String getRawName() {
return super.getName();
}

/**
* Public OAuth2 {@code client_id} presented by the client at the token and authorize endpoints.
*/
@JsonProperty private String clientId;

/**
* Client secret used for the {@code client_secret_basic} / {@code client_secret_post}
* authentication methods. Stored hashed; null for public clients and for clients that
* authenticate via {@code private_key_jwt}.
*/
@JsonProperty private String clientSecret;

/** Timestamp at which {@link #clientId} was issued. */
@JsonProperty private Date clientIdIssuedAt;

/** Optional expiry for {@link #clientSecret}; null means the secret does not expire. */
@JsonProperty private Date clientSecretExpiresAt;

/**
* Comma-separated list of OAuth2 client authentication methods the client may use at the token
* endpoint (e.g. {@code client_secret_basic}, {@code client_secret_post}, {@code
* private_key_jwt}, {@code none}). Parsed into Spring AS {@link
* org.springframework.security.oauth2.core.ClientAuthenticationMethod} values at load time.
*/
@JsonProperty private String clientAuthenticationMethods;

/**
* Comma-separated list of OAuth2 authorization grant types the client is permitted to use (e.g.
* {@code authorization_code}, {@code client_credentials}, {@code refresh_token}, {@code
* urn:ietf:params:oauth:grant-type:device_code}). Parsed into Spring AS {@link
* org.springframework.security.oauth2.core.AuthorizationGrantType} values via {@link
* org.hisp.dhis.security.oauth2.OAuth2GrantTypes#resolve(String)}.
*/
@JsonProperty private String authorizationGrantTypes;

/**
* Comma-separated list of registered redirect URIs used by the authorization-code and device-code
* flows; an incoming {@code redirect_uri} must match one of these exactly.
*/
@JsonProperty private String redirectUris;

/** Comma-separated list of post-logout redirect URIs allowed after OIDC RP-initiated logout. */
@JsonProperty private String postLogoutRedirectUris;

/**
* Comma-separated list of OAuth2 / OpenID Connect scopes (e.g. {@code openid}, {@code profile},
* {@code email}) the client is permitted to request.
*/
@JsonProperty private String scopes;

/**
* JSON-encoded Spring AS {@code ClientSettings}; controls client-level options such as whether
* user consent is required and PKCE requirements.
*/
@JsonProperty private String clientSettings;

/**
* JSON-encoded Spring AS {@code TokenSettings}; controls token lifetimes, access-token format
* (opaque vs JWT), refresh-token behavior and ID-token signature algorithm.
*/
@JsonProperty private String tokenSettings;
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,13 @@
import javax.annotation.Nonnull;
import org.hisp.dhis.common.IdentifiableObjectStore;

/** Store for OAuth2Client entities. */
/**
* Persistence store for {@link Dhis2OAuth2Client}. Used by the authorization server's {@link
* org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository}
* implementation to load clients at token-endpoint time.
*
* @author Morten Svanæs <msvanaes@dhis2.org>
*/
public interface Dhis2OAuth2ClientStore extends IdentifiableObjectStore<Dhis2OAuth2Client> {

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@
import org.hisp.dhis.common.DxfNamespaces;
import org.hisp.dhis.common.MetadataObject;

/**
* @author Morten Svanæs <msvanaes@dhis2.org>
*/
@Getter
@Setter
@JacksonXmlRootElement(localName = "oauth2AuthorizationConsent", namespace = DxfNamespaces.DXF_2_0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,15 @@
import javax.annotation.Nonnull;
import org.hisp.dhis.common.IdentifiableObjectStore;

/** Store for OAuth2AuthorizationConsent entities. */
/**
* Persistence store for {@link Dhis2OAuth2AuthorizationConsent}. Backs Spring Authorization
* Server's {@link
* org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService}
* implementation, which keys consent records on the composite {@code (registeredClientId,
* principalName)} pair: one record per (client, user) combination.
*
* @author Morten Svanæs <msvanaes@dhis2.org>
*/
public interface Dhis2OAuth2AuthorizationConsentStore
extends IdentifiableObjectStore<Dhis2OAuth2AuthorizationConsent> {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,19 +65,41 @@
import org.springframework.stereotype.Component;

/**
* Represent a custom AuthenticationManagerResolver to resolve authenticate JWT bearer token
* requests. This class will look up the corresponding issuer configuration ({@link
* DhisOidcClientRegistration}), based on the JWT "issuer" field information in the token and create
* a new {@link DhisJwtAuthenticationProvider} with the resolved config looked up in the {@link
* DhisOidcProviderRepository}
* Spring {@link AuthenticationManagerResolver} used by the OAuth2 resource-server filter chain to
* authenticate inbound JWT bearer token requests.
*
* <p>It will also create the authentication method to be called for authenticating the request.
* <p>This resolver implements DHIS2's JWT bearer authentication path, which lets OAuth2 clients
* call the DHIS2 API using a JWT access token issued either by DHIS2 itself (when the DHIS2
* Authorization Server is enabled) or by a trusted external OIDC IdP. It is only active when JWT
* bearer authentication has been enabled via configuration.
*
* <p>For each incoming request it:
*
* <ol>
* <li>Extracts the {@code iss} (issuer) claim from the bearer token using {@link
* JwtClaimIssuerConverter}.
* <li>Looks up the matching {@link DhisOidcClientRegistration} in {@link
* DhisOidcProviderRepository} by issuer URI. For DHIS2-issued tokens this resolves to the
* internal DHIS2 provider whose JWKS lives at {@code {server.base.url}/oauth2/jwks}; for
* external-IdP tokens the provider's {@code jwk_uri} is used.
* <li>Caches (per issuer) an {@link AuthenticationManager} that delegates to a {@link
* DhisJwtAuthenticationProvider}. The provider is configured with a {@link JwtDecoder} for
* signature and claim validation and a {@link Converter} that maps a validated {@link Jwt}
* into a {@link DhisJwtAuthenticationToken}.
* </ol>
*
* <p>The token converter enforces an audience check (against the registered client ids, or, for
* tokens issued by the DHIS2 Authorization Server itself, against registered OAuth2 clients), then
* resolves the DHIS2 user by reading the provider's mapping claim (currently {@code username} or
* {@code email}) and looking up the corresponding {@link UserDetails} through {@link UserService}.
* If no matching DHIS2 user exists, authentication is rejected with an {@link
* InvalidBearerTokenException}. User lookup here is eager so that the authenticated principal is
* fully populated before any non-OSIV (Open Session In View) endpoint runs.
*
* @author Morten Svanæs <msvanaes@dhis2.org>
* @see
* org.hisp.dhis.security.jwt.Dhis2JwtAuthenticationManagerResolver.DhisJwtAuthenticationProvider
* @see org.hisp.dhis.security.oidc.DhisOidcProviderRepository
* @see DhisJwtAuthenticationToken
* @see AuthenticationManagerResolver
* @author Morten Svanæs <msvanaes@dhis2.org>
*/
@Component
public class Dhis2JwtAuthenticationManagerResolver
Expand All @@ -96,10 +118,27 @@ public class Dhis2JwtAuthenticationManagerResolver

private JwtDecoder jwtDecoder;

/**
* Override the {@link JwtDecoder} used to validate bearer tokens. Intended for tests that need a
* fixed decoder instead of one resolved from the issuer location. When set, {@link
* #getDecoder(String)} returns this decoder for every issuer.
*
* @param jwtDecoder the decoder to use, or {@code null} to fall back to issuer-based resolution
*/
public void setJwtDecoder(JwtDecoder jwtDecoder) {
this.jwtDecoder = jwtDecoder;
}

/**
* Resolve the {@link AuthenticationManager} to use for the given request based on the {@code iss}
* claim of the bearer token.
*
* @param request the current HTTP request carrying the bearer token
* @return an {@link AuthenticationManager} backed by a {@link DhisJwtAuthenticationProvider}
* configured for the token's issuer
* @throws InvalidBearerTokenException if the token is missing, malformed, lacks an {@code iss}
* claim, or the issuer does not match any configured {@link DhisOidcClientRegistration}
*/
@Override
public AuthenticationManager resolve(HttpServletRequest request) {
String issuer = this.issuerConverter.convert(request);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,24 @@
import org.springframework.web.servlet.HandlerExceptionResolver;

/**
* Spring {@link AuthenticationEntryPoint} used by the OAuth2 resource-server filter chain to signal
* authentication failure for JWT bearer token requests.
*
* <p>On an unauthenticated or failed JWT request this entry point delegates to Spring Security's
* {@link BearerTokenAuthenticationEntryPoint}, which writes an RFC 6750 compliant {@code
* WWW-Authenticate: Bearer} challenge to the response. The exact challenge depends on the cause:
*
* <ul>
* <li>No credentials: a plain {@code Bearer realm="DHIS2"} challenge with HTTP 401.
* <li>Invalid or expired token (for example {@code org.springframework.security.oauth2.server
* .resource.InvalidBearerTokenException}): {@code error="invalid_token"} with HTTP 401.
* <li>Missing or insufficient scopes: {@code error="insufficient_scope"} with HTTP 403.
* </ul>
*
* <p>After emitting the WWW-Authenticate challenge, the entry point also forwards the {@link
* AuthenticationException} to the Spring MVC {@code HandlerExceptionResolver} so that DHIS2's
* global exception advice can log and translate the failure consistently with other API errors.
*
* @author Morten Svanæs <msvanaes@dhis2.org>
*/
@Component
Expand All @@ -52,6 +70,10 @@ public class DhisBearerJwtTokenAuthenticationEntryPoint
implements AuthenticationEntryPoint, ApplicationContextAware {
private ApplicationContext applicationContext;

/**
* Set by the Spring container to supply the {@link ApplicationContext} from which the {@code
* HandlerExceptionResolver} bean is looked up at request time.
*/
@Override
public void setApplicationContext(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
Expand All @@ -60,6 +82,18 @@ public void setApplicationContext(ApplicationContext applicationContext) {
private BearerTokenAuthenticationEntryPoint entryPoint =
new BearerTokenAuthenticationEntryPoint();

/**
* Commence the authentication failure response: write the RFC 6750 {@code WWW-Authenticate:
* Bearer} challenge (with {@code invalid_token} or {@code insufficient_scope} error codes as
* applicable) and then forward the exception to the Spring MVC {@code HandlerExceptionResolver}
* so DHIS2's global exception handling can render a consistent error payload.
*
* @param request the current HTTP request
* @param response the HTTP response to which the challenge is written
* @param authException the authentication failure that triggered this entry point
* @throws IOException if writing to the response fails
* @throws ServletException if the delegate entry point fails
*/
@Override
public void commence(
HttpServletRequest request,
Expand Down
Loading
Loading