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: OIDC - Fetch JWK from providers #3137

Merged
merged 47 commits into from
Oct 19, 2023
Merged
Show file tree
Hide file tree
Changes from 39 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
dde34ce
wip
Oct 11, 2023
4d6f1c2
fix test
Oct 11, 2023
f1b413a
add test for loading jwk
Oct 12, 2023
5f905bd
wip
Oct 12, 2023
8110205
update properties in integration tests
Oct 12, 2023
387dd45
fix messages, add client_id parameter
Oct 12, 2023
00b4fe7
use secret
Oct 12, 2023
d0c3005
Merge branch 'v2.x.x' into reboot/feat/oidc-jwk
Shobhajayanna Oct 15, 2023
941e4df
jwt token validation using jwkeys from the cache
Shobhajayanna Oct 15, 2023
03659b8
jwt token validation using jwkeys from the cache
Shobhajayanna Oct 15, 2023
944f691
Merge remote-tracking branch 'origin/v2.x.x' into reboot/feat/oidc-jwk
Oct 16, 2023
ff263df
wip expressions
Oct 16, 2023
10f3349
Merge branch 'v2.x.x' into reboot/feat/oidc-jwk
Shobhajayanna Oct 16, 2023
4d79d38
updat implementation
Oct 16, 2023
ccc7cca
remove unused methods and properties in test
Oct 16, 2023
9a146d9
Removed introspectUrl reference
Shobhajayanna Oct 16, 2023
f415fac
add new properties to start.sh
Oct 17, 2023
39e2d49
wip tests
Oct 17, 2023
1e98d20
wip - unit tests pass
Oct 17, 2023
96a5324
fix unit tests
Oct 17, 2023
31b8816
Merge branch 'v2.x.x' into reboot/feat/oidc-jwk
Shobhajayanna Oct 17, 2023
aee119a
value parameters not injected
Oct 17, 2023
f0cf93a
fix constructor in test
Oct 17, 2023
459f864
wip fix init
Oct 17, 2023
223def9
wip fix init
Oct 17, 2023
ad3006b
wip ITs
Shobhajayanna Oct 17, 2023
b2504b8
wip ITs
Shobhajayanna Oct 17, 2023
6345411
wip ITs
Shobhajayanna Oct 17, 2023
936db1d
wip fix startup
Oct 18, 2023
3a755c4
address comment
Oct 18, 2023
9a2de70
Merge branch 'v2.x.x' into reboot/feat/oidc-jwk
pablocarle Oct 18, 2023
dd2f59b
add unit tests
Oct 18, 2023
5285a6c
Merge branch 'reboot/feat/oidc-jwk' of https://github.com/zowe/api-la…
Oct 18, 2023
cd76719
wip ITs check
Shobhajayanna Oct 18, 2023
e4fe5ab
wip ITs check
Shobhajayanna Oct 18, 2023
15bd340
logs
Oct 18, 2023
bb45989
previous implementation
Oct 18, 2023
426ad5a
sonar
Oct 18, 2023
b9554be
Merge branch 'v2.x.x' into reboot/feat/oidc-jwk
Shobhajayanna Oct 18, 2023
5f83a37
parse token without signature to get kid
Oct 18, 2023
8af1622
make more testeable, add test for valid token
Oct 18, 2023
d8abecb
restore deleted log
Oct 18, 2023
9f2ce49
Merge branch 'v2.x.x' into reboot/feat/oidc-jwk
Shobhajayanna Oct 18, 2023
cff80de
Merge branch 'v2.x.x' into reboot/feat/oidc-jwk
pablocarle Oct 19, 2023
f0078f5
removed kid from the log
Shobhajayanna Oct 19, 2023
70c40a1
added jwt key information to the debug log
Shobhajayanna Oct 19, 2023
4bb91a3
added jwt key information to the debug log
Shobhajayanna Oct 19, 2023
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
2 changes: 1 addition & 1 deletion .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,9 @@ jobs:
env:
APIML_SECURITY_OIDC_CLIENTID: ${{ secrets.OKTA_CLIENT_ID }}
APIML_SECURITY_OIDC_CLIENTSECRET: ${{ secrets.OKTA_CLIENT_PASSWORD }}
APIML_SECURITY_OIDC_INTROSPECTURL: ${{ secrets.OKTA_INTROSPECT_URL }}
APIML_SECURITY_OIDC_ENABLED: true
APIML_SECURITY_OIDC_REGISTRY: zowe.okta.com
APIML_SECURITY_OIDC_JWKS_URI: ${{ secrets.OKTA_JWK_URI }}
APIML_SECURITY_OIDC_IDENTITYMAPPERUSER: APIMTST
APIML_SECURITY_OIDC_IDENTITYMAPPERURL: https://gateway-service:10010/zss/api/v1/certificate/dn
discovery-service:
Expand Down
3 changes: 2 additions & 1 deletion config/docker/gateway-service.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ apiml:
enabled: true
clientId:
clientSecret:
introspectUrl:
registry:
identityMapperUrl:
identityMapperUser:
jwks:
uri:
auth:
zosmf:
serviceId: mockzosmf # Replace me with the correct z/OSMF service id
Expand Down
3 changes: 2 additions & 1 deletion config/local/gateway-service.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ apiml:
enabled: false
clientId:
clientSecret:
introspectUrl:
registry:
identityMapperUrl:
identityMapperUser:
jwks:
uri:
auth:
jwt:
customAuthHeader:
Expand Down
6 changes: 4 additions & 2 deletions gateway-package/src/main/resources/bin/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,11 @@
# - ZWE_configs_apiml_security_oidc_enabled
# - ZWE_configs_apiml_security_oidc_clientId
# - ZWE_configs_apiml_security_oidc_clientSecret
# - ZWE_configs_apiml_security_oidc_introspectUrl
# - ZWE_configs_apiml_security_oidc_registry
# - ZWE_configs_apiml_security_oidc_identityMapperUrl
# - ZWE_configs_apiml_security_oidc_identityMapperUser
# - ZWE_configs_apiml_security_oidc_jwks_uri
# - ZWE_configs_apiml_security_oidc_jwks_refreshInternalHours
# - ZWE_configs_apiml_service_allowEncodedSlashes - Allows encoded slashes on on URLs through gateway
# - ZWE_configs_apiml_service_centralRegistryUrls - List of additional Discovery Services URLs to register with
# - ZWE_configs_apiml_service_corsEnabled
Expand Down Expand Up @@ -269,10 +270,11 @@ _BPX_JOBNAME=${ZWE_zowe_job_prefix}${GATEWAY_CODE} java \
-Dapiml.security.oidc.enabled=${ZWE_configs_apiml_security_oidc_enabled:-false} \
-Dapiml.security.oidc.clientId=${ZWE_configs_apiml_security_oidc_clientId:-} \
-Dapiml.security.oidc.clientSecret=${ZWE_configs_apiml_security_oidc_clientSecret:-} \
-Dapiml.security.oidc.introspectUrl=${ZWE_configs_apiml_security_oidc_introspectUrl:-} \
-Dapiml.security.oidc.registry=${ZWE_configs_apiml_security_oidc_registry:-} \
-Dapiml.security.oidc.identityMapperUrl=${ZWE_configs_apiml_security_oidc_identityMapperUrl:-"https://${ZWE_haInstance_hostname:-localhost}:${ZWE_configs_port:-7554}/zss/api/v1/certificate/dn"} \
-Dapiml.security.oidc.identityMapperUser=${ZWE_configs_apiml_security_oidc_identityMapperUser:-${ZWE_zowe_setup_security_users_zowe:-ZWESVUSR}} \
-Dapiml.security.oidc.jwks.uri=${ZWE_configs_apiml_security_oidc_jwks_uri} \
-Dapiml.security.oidc.jwks.refreshInternalHours=${ZWE_configs_apiml_security_oidc_jwks_refreshInternalHours:-1} \
-Djava.protocol.handler.pkgs=com.ibm.crypto.provider \
-Dloader.path=${GATEWAY_LOADER_PATH} \
-Djava.library.path=${LIBPATH} \
Expand Down
1 change: 0 additions & 1 deletion gateway-package/src/main/resources/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ configs:
enabled: false
clientId:
clientSecret:
introspectUrl:
registry:
# default value is https://${ZWE_haInstance_hostname:-localhost}:${ZWE_configs_port}/zss/api/v1/certificate/dn
identityMapperUrl:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@ public void evict() {
serviceCacheEvicts.forEach(x -> x.evictCacheService(serviceId));
}


}

}
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ public boolean isValid(AuthSource authSource) {
if (authSource instanceof OIDCAuthSource) {
String token = ((OIDCAuthSource) authSource).getRawSource();
if (StringUtils.isNotBlank(token)) {
logger.log(MessageType.DEBUG, "Validating OIDC token.");
if (oidcProvider.isValid(token)) {
logger.log(MessageType.DEBUG, "OIDC token is valid, set the distributed id to the auth source.");
QueryResponse tokenClaims = authenticationService.parseJwtToken(token);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* This program and the accompanying materials are made available under the terms of the
* Eclipse Public License v2.0 which accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-v20.html
*
* SPDX-License-Identifier: EPL-2.0
*
* Copyright Contributors to the Zowe Project.
*/

package org.zowe.apiml.gateway.security.service.token;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.List;

@Data
@AllArgsConstructor
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class JwkKeys {

private List<Key> keys;

@Data
@AllArgsConstructor
@NoArgsConstructor
public static class Key {

// Cryptographic algorithm family for the certificate's Key pair. i.e. RSA
@JsonProperty("kty")
private String kty;

// The algorithm used with the Key. i.e. RS256
@JsonProperty("alg")
private String alg;

// The certificate's Key ID
@JsonProperty("kid")
private String kid;

// How the Key is used. i.e. sig
@JsonProperty("use")
private String use;

// RSA Key value (exponent) for Key blinding
@JsonProperty("e")
private String e;

// RSA modulus value
@JsonProperty("n")
private String n;

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -12,111 +12,158 @@


import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.HttpEntity;
import org.apache.http.HttpStatus;
import org.apache.http.NameValuePair;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.message.BasicHeader;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.util.EntityUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
import org.zowe.apiml.security.common.token.OIDCProvider;
import org.zowe.apiml.util.UrlUtils;
import org.zowe.apiml.security.common.token.TokenNotValidException;

import javax.annotation.PostConstruct;

import java.io.IOException;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.security.Key;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.RSAPublicKeySpec;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

@RequiredArgsConstructor
@Service
@Slf4j
@ConditionalOnProperty(value = "apiml.security.oidc.enabled", havingValue = "true")
public class OIDCTokenProvider implements OIDCProvider {

@Value("${apiml.security.oidc.introspectUrl:}")
String introspectUrl;
@Value("${apiml.security.oidc.registry:}")
String registry;

@Value("${apiml.security.oidc.clientId:}")
String clientId;

@Value("${apiml.security.oidc.clientSecret:}")
String clientSecret;

@Value("${apiml.security.oidc.jwks.uri}")
private String jwksUri;

@Value("${apiml.security.oidc.jwks.refreshInternalHours:1}")
private int jwkRefreshInterval;

@Autowired
@Qualifier("secureHttpClientWithoutKeystore")
@NonNull
private final CloseableHttpClient httpClient;

private static final ObjectMapper mapper = new ObjectMapper();
private ObjectMapper mapper = new ObjectMapper();

@Override
public boolean isValid(String token) {
OIDCTokenClaims claims = introspect(token);
if (claims != null) {
return claims.getActive();
}
return false;
private Map<String, Key> jwks = new ConcurrentHashMap<>();

@PostConstruct
public void afterPropertiesSet() {
this.fetchJwksUrls();
Executors.newSingleThreadScheduledExecutor(r -> new Thread("OIDC JWK Refresh"))
.scheduleAtFixedRate(this::fetchJwksUrls , jwkRefreshInterval, jwkRefreshInterval, TimeUnit.HOURS);
}

private OIDCTokenClaims introspect(String token) {
if (StringUtils.isBlank(token)) {
log.debug("No token has been provided.");
return null;
}
if (StringUtils.isBlank(introspectUrl) || !UrlUtils.isValidUrl(introspectUrl)) {
log.warn("Missing or invalid introspectUrl configuration. Cannot proceed with token validation.");
return null;
}
if (StringUtils.isBlank(clientId) || StringUtils.isBlank(clientSecret)) {
log.warn("Missing clientId or clientSecret configuration. Cannot proceed with token validation.");
return null;
@Retryable
void fetchJwksUrls() {
if (StringUtils.isBlank(jwksUri)) {
log.debug("OIDC JWK URI not provided, JWK refresh not performed");
return;
}
HttpPost post = new HttpPost(introspectUrl);
List<NameValuePair> bodyParams = new ArrayList<>();
bodyParams.add(new BasicNameValuePair("token", token));
bodyParams.add(new BasicNameValuePair("token_type_hint", "access_token"));
post.setEntity(new UrlEncodedFormEntity(bodyParams, StandardCharsets.UTF_8));

String credentials = clientId + ":" + clientSecret;
byte[] base64encoded = Base64.getEncoder().encode(credentials.getBytes());
final String headerValue = "Basic " + new String(base64encoded);
post.setHeader(new BasicHeader(HttpHeaders.AUTHORIZATION, headerValue));
post.setHeader(new BasicHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE));
post.setHeader(new BasicHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE));

log.debug("Refreshing JWK endpoints {}", jwksUri);
HttpGet getRequest = new HttpGet(jwksUri + "?client_id=" + clientId);
try {
CloseableHttpResponse response = httpClient.execute(post);
CloseableHttpResponse response = httpClient.execute(getRequest);
final int statusCode = response.getStatusLine() != null ? response.getStatusLine().getStatusCode() : 0;
final HttpEntity responseEntity = response.getEntity();
String responseBody = "";
if (responseEntity != null) {
responseBody = EntityUtils.toString(responseEntity, StandardCharsets.UTF_8);
}
if (statusCode == HttpStatus.SC_OK && !responseBody.isEmpty()) {
return mapper.readValue(responseBody, OIDCTokenClaims.class);
jwks.clear();
JwkKeys jwkKeys = mapper.readValue(responseBody, JwkKeys.class);
jwks.putAll(processKeys(jwkKeys));
} else {
log.error("Failed to validate the OIDC access token. Unexpected response: {}", statusCode);
return null;
log.error("Failed to obtain JWKs from URI {}. Unexpected response: {}, response text: {}", jwksUri, statusCode, responseBody);
}
} catch (IOException e) {
log.error("Failed to validate the OIDC access token. ", e);
} catch (IOException | IllegalStateException e) {
log.error("Error processing response from URI {}", jwksUri, e.getMessage());
}
return null;
}

private Map<String, Key> processKeys(JwkKeys jwkKeys) {
return jwkKeys.getKeys().stream()
.filter(jwkKey -> "sig".equals(jwkKey.getUse()))
.filter(jwkKey -> "RSA".equals(jwkKey.getKty()))
.collect(Collectors.toMap(JwkKeys.Key::getKid, jwkKey -> {
BigInteger modulus = base64ToBigInteger(jwkKey.getN());
BigInteger exponent = base64ToBigInteger(jwkKey.getE());
RSAPublicKeySpec rsaPublicKeySpec = new RSAPublicKeySpec(modulus, exponent);
try {
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
return keyFactory.generatePublic(rsaPublicKeySpec);
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
throw new IllegalStateException("Failed to parse public key");
}
}));
}

private BigInteger base64ToBigInteger(String value) {
return new BigInteger(1, Decoders.BASE64URL.decode(value));
}

@Override
public boolean isValid(String token) {
if (StringUtils.isBlank(token)) {
log.debug("No token has been provided.");
return false;
}
Claims claims = null;
for (Map.Entry<String, Key> entry : jwks.entrySet()) {
claims = validate(token, entry.getValue());
pablocarle marked this conversation as resolved.
Show resolved Hide resolved
if (claims != null && !claims.isEmpty()) {
return true;
}
}

return false;
}

private Claims validate(String token, Key key) {
try {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
} catch (TokenNotValidException | JwtException e) {
log.debug("OIDC Token is not valid: {}", e.getMessage());
return null; // NOSONAR
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

Expand All @@ -25,7 +24,6 @@

@RequiredArgsConstructor
@Service
@Slf4j
public class StaticWebFingerProvider implements WebFingerProvider {

@Value("${apiml.security.webfinger.fileLocation:-}")
Expand Down
3 changes: 2 additions & 1 deletion gateway-service/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,11 @@ apiml:
enabled: false
clientId:
clientSecret:
introspectUrl:
registry:
identityMapperUrl:
identityMapperUser:
jwks:
uri:
auth:
jwt:
customAuthHeader:
Expand Down
Loading