Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add new option allowing to check confirmation method #89

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
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
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,
jhaeyaert marked this conversation as resolved.
Show resolved Hide resolved
"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);
}
leleueri marked this conversation as resolved.
Show resolved Hide resolved
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