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 @@ -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 @@ -62,6 +62,8 @@
* /api/metadata} export; token-bearing fields are additionally {@link JsonIgnore}'d so no REST
* surface can leak them even on explicit requests. Persistence uses Hibernate field access ({@code
* Dhis2OAuth2Authorization.hbm.xml}) and is independent of the JSON annotations.
*
* @author Morten Svanæs <msvanaes@dhis2.org>
*/
@Getter
@Setter
Expand All @@ -73,11 +75,32 @@ public class Dhis2OAuth2Authorization extends BaseIdentifiableObject
/** Required by Hibernate + Jackson for reflective instantiation. */
public 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.). */
@JsonIgnore private String attributes;

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

@JsonIgnore 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 @@ -60,6 +60,8 @@
* <p>Marked {@link SecondaryMetadataObject} so the type is excluded from the default {@code
* /api/metadata} export. Persistence uses Hibernate field access ({@code
* Dhis2OAuth2AuthorizationConsent.hbm.xml}) and is independent of the JSON annotations.
*
* @author Morten Svanæs <msvanaes@dhis2.org>
*/
@Getter
@Setter
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 @@ -32,19 +32,44 @@
import java.util.List;
import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;

/**
* DHIS2 service for persisting OAuth2 authorizations (issued grants). Backs Spring Authorization
* Server's {@link
* org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService}.
*
* <p>One row is stored per grant and holds every token value issued for that grant for its
* lifetime: authorization code, access token, refresh token, OIDC ID token, user code, and device
* code. Each is also independently indexed so token-introspection lookups can find the parent
* authorization by any of those values.
*
* @author Morten Svanæs <msvanaes@dhis2.org>
*/
public interface Dhis2OAuth2AuthorizationService {
/** Persist a new authorization or update the existing row with the same id. */
void save(
org.springframework.security.oauth2.server.authorization.OAuth2Authorization authorization);

/**
* Remove the persisted authorization identified by the given Spring-AS {@code
* OAuth2Authorization}.
*/
void remove(
org.springframework.security.oauth2.server.authorization.OAuth2Authorization authorization);

/** Delete the persisted authorization with the given DHIS2 UID. */
void delete(String uid);

/** Return all persisted authorizations. */
List<Dhis2OAuth2Authorization> getAll();

/** Look up an authorization by its id. Returns {@code null} if no match. */
org.springframework.security.oauth2.server.authorization.OAuth2Authorization findById(String id);

/**
* Look up an authorization by token value. When {@code tokenType} is {@code null}, all token
* columns are searched; otherwise the column matching {@link OAuth2TokenType} (or equivalent
* Spring-AS parameter name) is used. Returns {@code null} if no match.
*/
org.springframework.security.oauth2.server.authorization.OAuth2Authorization findByToken(
String token, OAuth2TokenType tokenType);
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,19 @@
import org.springframework.util.StringUtils;

/**
* DHIS2 implementation of Spring Authorization Server's OAuth2AuthorizationService that uses
* HibernateOAuth2AuthorizationStore for persistence.
* Spring-bean implementation of {@link Dhis2OAuth2AuthorizationService} and Spring Authorization
* Server's {@link OAuth2AuthorizationService}. Persistence is delegated to {@link
* Dhis2OAuth2AuthorizationStore} (Hibernate-backed).
*
* <p>Spring AS models an issued grant as an {@link OAuth2Authorization} aggregate holding up to six
* distinct token objects (authorization code, access token, refresh token, OIDC ID token, user
* code, device code), each with its own issued-at / expires-at / metadata map. This implementation
* flattens that aggregate into a single {@link Dhis2OAuth2Authorization} row with per-token
* columns, and {@link #toObject} / {@link #toEntity} translate in both directions.
*
* <p>The Jackson {@link ObjectMapper} is preloaded with {@link SecurityJackson2Modules} and {@link
* OAuth2AuthorizationServerJackson2Module} so the {@code attributes}, per-token metadata, and OIDC
* ID-token claims JSON round-trip correctly.
*
* @author Morten Svanæs <msvanaes@dhis2.org>
*/
Expand Down Expand Up @@ -112,6 +123,15 @@ public Dhis2OAuth2AuthorizationServiceImpl(
this.objectMapper.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
}

/**
* {@inheritDoc}
*
* <p>If an authorization with the same id already exists it is merged; otherwise a new row is
* inserted. The {@code createdBy} user is resolved from the current security context: a {@link
* Jwt} principal (the DCR Initial Access Token) resolves to the token's {@code sub} claim; an
* {@link OAuth2ClientAuthenticationToken} resolves to the client's registered {@code createdBy}
* user; other cases fall through to the default store behaviour.
*/
@Transactional
@Override
public void save(OAuth2Authorization authorization) {
Expand Down Expand Up @@ -173,6 +193,14 @@ public OAuth2Authorization findById(String id) {
return entity != null ? toObject(entity) : null;
}

/**
* {@inheritDoc}
*
* <p>When {@code tokenType} is {@code null} all token columns are searched. Otherwise the column
* matching the Spring AS parameter name is used: {@code state}, {@code code}, {@code
* access_token}, {@code refresh_token}, {@code id_token}, {@code user_code}, or {@code
* device_code}.
*/
@Transactional(readOnly = true)
@Override
public OAuth2Authorization findByToken(String token, OAuth2TokenType tokenType) {
Expand Down
Loading
Loading