Skip to content

Commit

Permalink
feat: add new option allowing to check confirmation method
Browse files Browse the repository at this point in the history
  - the option is enabled on the oauth2 policy and only support bound certificate (x5t#S256)
  • Loading branch information
guillaumelamirand committed Sep 5, 2023
1 parent ee5d7d6 commit cd3d026
Show file tree
Hide file tree
Showing 17 changed files with 934 additions and 55 deletions.
42 changes: 41 additions & 1 deletion README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,36 @@ APIM supports two types of authorization server:
^.^|array of string
|===

=== Specific configuration for Confirmation Method validation

|===
|Property |Required |Description |Type| Default

.^|confirmationMethodValidation.ignoreMissing
^.^|-
|Will ignore CNF validation if the token doesn't contain any CNF information.
^.^|boolean
^.^|false

.^|confirmationMethodValidation.certificateBoundThumbprint.enabled
^.^|-
|Will validate the certificate thumbprint extracted from the access_token with the one provided by the client.
^.^|boolean
^.^|false

.^|confirmationMethodValidation.certificateBoundThumbprint.extractCertificateFromHeader
^.^|-
|Enabled to extract the client certificate from request header. Necessary when the M-TLS connection is handled by a proxy.
^.^|boolean
^.^|false

.^|confirmationMethodValidation.certificateBoundThumbprint.headerName
^.^|-
|Name of the header where to find the client certificate.
^.^|string
^.^|ssl-client-cert
|===

=== Configuration example

[source, json]
Expand All @@ -131,7 +161,15 @@ APIM supports two types of authorization server:
"oauthCacheResource": "cache-resource-name",
"extractPayload": true,
"checkRequiredScopes": true,
"requiredScopes": ["openid", "resource:read", "resource:write"]
"requiredScopes": ["openid", "resource:read", "resource:write"],
"confirmationMethodValidation" : {
"ignoreMissing": false,
"certificateBoundThumbprint" : {
"enabled": false,
"extractCertificateFromHeader": false,
"headerName": "ssl-client-cert"
}
}
}
}
----
Expand All @@ -154,6 +192,8 @@ APIM supports two types of authorization server:

* Access token can not be validated by authorization server

* Confirmation method can not be validated

.^| ```403```
| Issue encountered:

Expand Down
4 changes: 2 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@
<gravitee-gateway-api.version>3.2.1</gravitee-gateway-api.version>
<gravitee-policy-api.version>1.11.0</gravitee-policy-api.version>
<gravitee-node.version>4.0.0</gravitee-node.version>
<gravitee-common.version>2.1.1</gravitee-common.version>
<gravitee-apim.version>4.0.0-SNAPSHOT</gravitee-apim.version>
<gravitee-common.version>3.2.0</gravitee-common.version>
<gravitee-apim.version>4.1.0-SNAPSHOT</gravitee-apim.version>
<gravitee-resource-api.version>1.1.0</gravitee-resource-api.version>
<gravitee-resource-oauth2-provider-api.version>1.3.0</gravitee-resource-oauth2-provider-api.version>
<gravitee-resource-cache-provider-api.version>1.4.0</gravitee-resource-cache-provider-api.version>
Expand Down
18 changes: 18 additions & 0 deletions src/main/java/io/gravitee/policy/oauth2/Oauth2Policy.java
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,24 @@ private Completable validateOAuth2Payload(
}
}

// Check confirmation Method and certificate thumbprint
OAuth2PolicyConfiguration.ConfirmationMethodValidation confirmationMethodValidation =
oAuth2PolicyConfiguration.getConfirmationMethodValidation();
if (confirmationMethodValidation != null && confirmationMethodValidation.getCertificateBoundThumbprint().isEnabled()) {
String tokenThumbprint = tokenIntrospectionResult.extractPath(OAUTH_PAYLOAD_CNF).path(OAUTH_PAYLOAD_X5T).asText();
if (
!isValidCertificateThumbprint(
tokenThumbprint,
ctx.request().sslSession(),
ctx.request().headers(),
confirmationMethodValidation.isIgnoreMissing(),
confirmationMethodValidation.getCertificateBoundThumbprint()
)
) {
return sendError(ctx, OAUTH2_INVALID_CERTIFICATE_BOUND_THUMBPRINT);
}
}

// Store OAuth2 payload into execution context if required
if (oAuth2PolicyConfiguration.isExtractPayload()) {
ctx.setAttribute(CONTEXT_ATTRIBUTE_OAUTH_PAYLOAD, tokenIntrospectionResult.getOauth2ResponsePayload());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,19 @@
import io.gravitee.policy.api.PolicyConfiguration;
import java.util.ArrayList;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

/**
* @author David BRASSELY (david.brassely at graviteesource.com)
* @author GraviteeSource Team
*/
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class OAuth2PolicyConfiguration implements PolicyConfiguration {

private String oauthResource;
Expand All @@ -31,61 +39,29 @@ public class OAuth2PolicyConfiguration implements PolicyConfiguration {
private boolean checkRequiredScopes = false;
private List<String> requiredScopes = new ArrayList<>();
private boolean modeStrict = true;
private boolean propagateAuthHeader = true;

public String getOauthResource() {
return oauthResource;
}

public void setOauthResource(String oauthResource) {
this.oauthResource = oauthResource;
}

public boolean isExtractPayload() {
return extractPayload;
}

public void setExtractPayload(boolean extractPayload) {
this.extractPayload = extractPayload;
}

public boolean isCheckRequiredScopes() {
return checkRequiredScopes;
}

public void setCheckRequiredScopes(boolean checkRequiredScopes) {
this.checkRequiredScopes = checkRequiredScopes;
}

public List<String> getRequiredScopes() {
return requiredScopes;
}
private ConfirmationMethodValidation confirmationMethodValidation = new ConfirmationMethodValidation();

public void setRequiredScopes(List<String> requiredScopes) {
this.requiredScopes = requiredScopes;
}

public boolean isModeStrict() {
return modeStrict;
}
private boolean propagateAuthHeader = true;

public void setModeStrict(boolean modeStrict) {
this.modeStrict = modeStrict;
}
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public static class ConfirmationMethodValidation {

public boolean isPropagateAuthHeader() {
return propagateAuthHeader;
private boolean ignoreMissing = false;
private CertificateBoundThumbprint certificateBoundThumbprint = new CertificateBoundThumbprint();
}

public void setPropagateAuthHeader(boolean propagateAuthHeader) {
this.propagateAuthHeader = propagateAuthHeader;
}

public String getOauthCacheResource() {
return oauthCacheResource;
}
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public static class CertificateBoundThumbprint {

public void setOauthCacheResource(String oauthCacheResource) {
this.oauthCacheResource = oauthCacheResource;
private boolean enabled = false;
private boolean extractCertificateFromHeader = false;
private String headerName = "ssl-client-cert";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,9 @@ public TokenIntrospectionResult(String oauth2ResponsePayload) {
}

public String getClientId() {
if (hasValidPayload()) {
return oAuth2ResponseJsonNode.path(OAUTH_PAYLOAD_CLIENT_ID_NODE).asText();
JsonNode extractPath = extractPath(OAUTH_PAYLOAD_CLIENT_ID_NODE);
if (extractPath != null) {
return extractPath.asText();
}
return null;
}
Expand Down Expand Up @@ -101,8 +102,16 @@ public List<String> extractScopes(String scopeSeparator) {
}

public String extractUser(String userClaim) {
JsonNode extractPath = extractPath(userClaim == null ? OAUTH_PAYLOAD_SUB_NODE : userClaim);
if (extractPath != null) {
return extractPath.asText();
}
return null;
}

public JsonNode extractPath(String path) {
if (hasValidPayload()) {
return oAuth2ResponseJsonNode.path(userClaim == null ? OAUTH_PAYLOAD_SUB_NODE : userClaim).asText();
return oAuth2ResponseJsonNode.path(path);
}
return null;
}
Expand Down
54 changes: 54 additions & 0 deletions src/main/java/io/gravitee/policy/v3/oauth2/Oauth2PolicyV3.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,13 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import io.gravitee.common.http.HttpStatusCode;
import io.gravitee.common.security.CertificateUtils;
import io.gravitee.gateway.api.ExecutionContext;
import io.gravitee.gateway.api.Request;
import io.gravitee.gateway.api.Response;
import io.gravitee.gateway.api.handler.Handler;
import io.gravitee.gateway.api.http.HttpHeaderNames;
import io.gravitee.gateway.api.http.HttpHeaders;
import io.gravitee.policy.api.PolicyChain;
import io.gravitee.policy.api.PolicyResult;
import io.gravitee.policy.api.annotations.OnRequest;
Expand All @@ -39,11 +41,14 @@
import io.gravitee.resource.oauth2.api.OAuth2Resource;
import io.gravitee.resource.oauth2.api.OAuth2Response;
import java.io.IOException;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import java.util.Optional;
import javax.net.ssl.SSLSession;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.util.StringUtils;
Expand All @@ -64,6 +69,8 @@ public class Oauth2PolicyV3 {
public static final String OAUTH_PAYLOAD_CLIENT_ID_NODE = "client_id";
public static final String OAUTH_PAYLOAD_SUB_NODE = "sub";
public static final String OAUTH_PAYLOAD_EXP = "exp";
public static final String OAUTH_PAYLOAD_CNF = "cnf";
public static final String OAUTH_PAYLOAD_X5T = "x5t#S256";

public static final String CONTEXT_ATTRIBUTE_PREFIX = "oauth.";
public static final String CONTEXT_ATTRIBUTE_OAUTH_PAYLOAD = CONTEXT_ATTRIBUTE_PREFIX + "payload";
Expand All @@ -76,6 +83,7 @@ public class Oauth2PolicyV3 {
public static final String OAUTH2_INVALID_ACCESS_TOKEN_KEY = "OAUTH2_INVALID_ACCESS_TOKEN";
public static final String OAUTH2_INVALID_SERVER_RESPONSE_KEY = "OAUTH2_INVALID_SERVER_RESPONSE";
public static final String OAUTH2_INSUFFICIENT_SCOPE_KEY = "OAUTH2_INSUFFICIENT_SCOPE";
public static final String OAUTH2_INVALID_CERTIFICATE_BOUND_THUMBPRINT = "OAUTH2_INVALID_CERTIFICATE_BOUND_THUMBPRINT";
public static final String OAUTH2_SERVER_UNAVAILABLE_KEY = "OAUTH2_SERVER_UNAVAILABLE";

public static final String OAUTH2_UNAUTHORIZED_MESSAGE = "Unauthorized";
Expand Down Expand Up @@ -224,6 +232,25 @@ private void handleSuccess(
}
}

// Check confirmation Method and certificate thumbprint
OAuth2PolicyConfiguration.ConfirmationMethodValidation confirmationMethodValidation =
oAuth2PolicyConfiguration.getConfirmationMethodValidation();
if (confirmationMethodValidation != null && confirmationMethodValidation.getCertificateBoundThumbprint().isEnabled()) {
String tokenThumbprint = oauthResponseNode.path(OAUTH_PAYLOAD_CNF).path(OAUTH_PAYLOAD_X5T).asText();
if (
!isValidCertificateThumbprint(
tokenThumbprint,
request.sslSession(),
request.headers(),
confirmationMethodValidation.isIgnoreMissing(),
confirmationMethodValidation.getCertificateBoundThumbprint()
)
) {
sendError(OAUTH2_INVALID_CERTIFICATE_BOUND_THUMBPRINT, response, policyChain);
return;
}
}

// Store OAuth2 payload into execution context if required
if (oAuth2PolicyConfiguration.isExtractPayload()) {
executionContext.setAttribute(CONTEXT_ATTRIBUTE_OAUTH_PAYLOAD, oauth2payload);
Expand Down Expand Up @@ -301,4 +328,31 @@ protected static boolean hasRequiredScopes(Collection<String> tokenScopes, List<
return tokenScopes.stream().anyMatch(requiredScopes::contains);
}
}

protected static boolean isValidCertificateThumbprint(
final String tokenThumbprint,
final SSLSession sslSession,
final HttpHeaders headers,
final boolean ignoreMissingCnf,
final OAuth2PolicyConfiguration.CertificateBoundThumbprint certificateBoundThumbprint
) {
// Ignore empty configuration method
if (!StringUtils.hasText(tokenThumbprint) && ignoreMissingCnf) {
return true;
} else if (!StringUtils.hasText(tokenThumbprint) && !ignoreMissingCnf) {
return false;
}

// Compute client certificate thumbprint
Optional<X509Certificate> clientCertificate;
if (certificateBoundThumbprint.isExtractCertificateFromHeader()) {
clientCertificate = CertificateUtils.extractCertificate(headers, certificateBoundThumbprint.getHeaderName());
} else {
clientCertificate = CertificateUtils.extractPeerCertificate(sslSession);
}
return clientCertificate
.map(x509Certificate -> CertificateUtils.generateThumbprint(x509Certificate, "SHA-256"))
.map(tokenThumbprint::equals)
.orElse(false);
}
}
37 changes: 37 additions & 0 deletions src/main/resources/schemas/schema-form.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,43 @@
"type": "boolean",
"default": true
},
"confirmationMethodValidation": {
"type": "object",
"title": "Confirmation Method Validation",
"properties": {
"ignoreMissing": {
"title": "Ignore missing CNF",
"description": "Will ignore CNF validation if the token doesn't contain any CNF information. Default is false.",
"type": "boolean",
"default": false
},
"certificateBoundThumbprint": {
"type": "object",
"title": "Certificate Bound thumbprint (x5t#S256)",
"properties": {
"enabled": {
"title": "Enable certificate bound thumbprint validation",
"description": "Will validate the certificate thumbprint extracted from the access_token with the one provided by the client. The default is false.",
"type": "boolean",
"default": false
},
"extractCertificateFromHeader": {
"title": "Extract client certificate from headers",
"description": "Enabled to extract the client certificate from request header. Necessary when the M-TLS connection is handled by a proxy.",
"type": "boolean",
"default": false
},
"headerName": {
"title": "Header name",
"description": "Name of the header where to find the client certificate.",
"type": "string",
"default": "ssl-client-cert"
}
}
}
},
"additionalProperties": false
},
"propagateAuthHeader": {
"title": "Permit authorization header to the target endpoints",
"description": "Allows to propagate Authorization header to the target endpoints",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public class DummyOAuth2Resource extends OAuth2Resource<DummyOAuth2Resource.Dumm
public static String TOKEN_SUCCESS_WITHOUT_CLIENT_ID = "my-test-token-2";
public static String TOKEN_SUCCESS_WITH_INVALID_PAYLOAD = "my-test-token-3";
public static String TOKEN_FAIL = "my-test-token-4";
public static String TOKEN_SUCCESS_WITH_CNF = "my-test-token-5";

public static String CLIENT_ID = "my-test-client-id";

Expand All @@ -43,6 +44,12 @@ public void introspect(String accessToken, Handler<OAuth2Response> responseHandl
response = new OAuth2Response(true, "{}");
} else if (TOKEN_SUCCESS_WITH_INVALID_PAYLOAD.equals(accessToken)) {
response = new OAuth2Response(true, "{this _is _invalid json");
} else if (TOKEN_SUCCESS_WITH_CNF.equals(accessToken)) {
response =
new OAuth2Response(
true,
"{\"client_id\": \"" + CLIENT_ID + "\", \"cnf\": { \"x5t#S256\" : \"2oHrNOqScxD8EHkb7_GYmnNvWqGj5M31Dqsrk3Jl2Yk\"}}"
);
} else if (TOKEN_FAIL.equals(accessToken)) {
response = new OAuth2Response(false, null);
} else {
Expand Down
Loading

0 comments on commit cd3d026

Please sign in to comment.