diff --git a/libs/java/auth_core/src/main/java/com/yahoo/athenz/auth/TokenExchangeIdentityProvider.java b/libs/java/auth_core/src/main/java/com/yahoo/athenz/auth/TokenExchangeIdentityProvider.java new file mode 100644 index 00000000000..22b66e8a51c --- /dev/null +++ b/libs/java/auth_core/src/main/java/com/yahoo/athenz/auth/TokenExchangeIdentityProvider.java @@ -0,0 +1,57 @@ +/* + * Copyright The Athenz Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.yahoo.athenz.auth; + +import com.yahoo.athenz.auth.token.OAuth2Token; + +import java.util.List; + +public interface TokenExchangeIdentityProvider { + + /** + * Return the corresponding athenz identity for the principal identity + * from the given token. The token has already been validated by the server. + * This could be used when issuing JAG tokens and the subject token is issued + * by an external Identity Provider. Similarly, it could be used when exchanging + * JAG tokens from an external Identity Provider with an Athenz issued access + * token. + * + * @param token validated oauth2 token from external Identity Provider + * @return the identity of the token in Athenz system. + */ + String getTokenIdentity(OAuth2Token token); + + /** + * Return the audience value to be used for the token exchange. + * Typically, if this is an ID token then the audience would be + * included in the aud claim. However, if this is an access token + * then the audience might be a different value and the actual client + * id would be included in the cid or a different claim. + * + * @param token validated oauth2 token from external Identity Provider + * @return the audience value + */ + String getTokenAudience(OAuth2Token token); + + /** + * Return the list of claims that should be included in the + * generated token as part of the exchange request in addition + * to the standard claims (iss, sub, aud, exp, iat, scp). + * @return list of claim names + */ + List getTokenExchangeClaims(); +} diff --git a/libs/java/auth_core/src/main/java/com/yahoo/athenz/auth/token/AccessToken.java b/libs/java/auth_core/src/main/java/com/yahoo/athenz/auth/token/AccessToken.java index d6a1c3a6172..4ba5a61a792 100644 --- a/libs/java/auth_core/src/main/java/com/yahoo/athenz/auth/token/AccessToken.java +++ b/libs/java/auth_core/src/main/java/com/yahoo/athenz/auth/token/AccessToken.java @@ -505,7 +505,7 @@ public String getSignedToken(final PrivateKey key, final String keyId, final Str try { JWSSigner signer = JwtsHelper.getJWSSigner(key); - JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + JWTClaimsSet.Builder claimsSetBuilder = new JWTClaimsSet.Builder() .subject(subject) .jwtID(jwtId) .issueTime(Date.from(Instant.ofEpochSecond(issueTime))) @@ -521,9 +521,13 @@ public String getSignedToken(final PrivateKey key, final String keyId, final Str .claim(CLAIM_CONFIRM, confirm) .claim(CLAIM_PROXY, proxyPrincipal) .claim(CLAIM_AUTHZ_DETAILS, authorizationDetails) - .claim(CLAIM_RESOURCE, resource) - .build(); - + .claim(CLAIM_RESOURCE, resource); + if (customClaims != null) { + for (Map.Entry entry : customClaims.entrySet()) { + claimsSetBuilder.claim(entry.getKey(), entry.getValue()); + } + } + JWTClaimsSet claimsSet = claimsSetBuilder.build(); SignedJWT signedJWT = new SignedJWT( new JWSHeader.Builder(JWSAlgorithm.parse(sigAlg)) .type(new JOSEObjectType(tokenType)) @@ -541,4 +545,24 @@ public String getSignedToken(final PrivateKey key, final String keyId, final Str public String getSignedToken(final PrivateKey key, final String keyId, final String sigAlg) { return getSignedToken(key, keyId, sigAlg, HDR_TOKEN_JWT); } + + @Override + public boolean isStandardClaim(final String claimName) { + if (super.isStandardClaim(claimName)) { + return true; + } + switch (claimName) { + case CLAIM_SCOPE: + case CLAIM_SCOPE_STD: + case CLAIM_UID: + case CLAIM_CLIENT_ID: + case CLAIM_CONFIRM: + case CLAIM_PROXY: + case CLAIM_AUTHZ_DETAILS: + case CLAIM_RESOURCE: + return true; + default: + return false; + } + } } diff --git a/libs/java/auth_core/src/main/java/com/yahoo/athenz/auth/token/IdToken.java b/libs/java/auth_core/src/main/java/com/yahoo/athenz/auth/token/IdToken.java index 12526144fee..5c958a9aeb0 100644 --- a/libs/java/auth_core/src/main/java/com/yahoo/athenz/auth/token/IdToken.java +++ b/libs/java/auth_core/src/main/java/com/yahoo/athenz/auth/token/IdToken.java @@ -30,6 +30,7 @@ import java.time.Instant; import java.util.Date; import java.util.List; +import java.util.Map; public class IdToken extends OAuth2Token { @@ -107,7 +108,7 @@ public String getSignedToken(final PrivateKey key, final String keyId, final Str try { JWSSigner signer = JwtsHelper.getJWSSigner(key); - JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + JWTClaimsSet.Builder claimsSetBuilder = new JWTClaimsSet.Builder() .subject(subject) .issueTime(Date.from(Instant.ofEpochSecond(issueTime))) .expirationTime(Date.from(Instant.ofEpochSecond(expiryTime))) @@ -116,9 +117,13 @@ public String getSignedToken(final PrivateKey key, final String keyId, final Str .claim(CLAIM_AUTH_TIME, authTime) .claim(CLAIM_VERSION, version) .claim(CLAIM_GROUPS, groups) - .claim(CLAIM_NONCE, nonce) - .build(); - + .claim(CLAIM_NONCE, nonce); + if (customClaims != null) { + for (Map.Entry entry : customClaims.entrySet()) { + claimsSetBuilder.claim(entry.getKey(), entry.getValue()); + } + } + JWTClaimsSet claimsSet = claimsSetBuilder.build(); SignedJWT signedJWT = new SignedJWT( new JWSHeader.Builder(JWSAlgorithm.parse(sigAlg)) .keyID(keyId) @@ -131,4 +136,18 @@ public String getSignedToken(final PrivateKey key, final String keyId, final Str return null; } } + + @Override + public boolean isStandardClaim(final String claimName) { + if (super.isStandardClaim(claimName)) { + return true; + } + switch (claimName) { + case CLAIM_GROUPS: + case CLAIM_NONCE: + return true; + default: + return false; + } + } } diff --git a/libs/java/auth_core/src/main/java/com/yahoo/athenz/auth/token/OAuth2Token.java b/libs/java/auth_core/src/main/java/com/yahoo/athenz/auth/token/OAuth2Token.java index d40a133faab..b7e0632a7eb 100644 --- a/libs/java/auth_core/src/main/java/com/yahoo/athenz/auth/token/OAuth2Token.java +++ b/libs/java/auth_core/src/main/java/com/yahoo/athenz/auth/token/OAuth2Token.java @@ -28,6 +28,7 @@ import java.security.PublicKey; import java.util.Date; import java.util.List; +import java.util.Map; public class OAuth2Token { @@ -36,6 +37,13 @@ public class OAuth2Token { public static final String CLAIM_VERSION = "ver"; public static final String CLAIM_AUTH_TIME = "auth_time"; + public static final String CLAIM_ISSUER = "iss"; + public static final String CLAIM_SUBJECT = "sub"; + public static final String CLAIM_AUDIENCE = "aud"; + public static final String CLAIM_EXPIRY = "exp"; + public static final String CLAIM_ISSUE_TIME = "iat"; + public static final String CLAIM_NOT_BEFORE = "nbf"; + public static final String CLAIM_JWT_ID = "jti"; protected int version; protected long expiryTime; @@ -49,6 +57,7 @@ public class OAuth2Token { protected String clientIdDomainName; protected String clientIdServiceName; protected JWTClaimsSet claimsSet = null; + protected Map customClaims = null; protected static DefaultJWTClaimsVerifier claimsVerifier = new DefaultJWTClaimsVerifier<>(null, null); public OAuth2Token() { @@ -358,4 +367,49 @@ public String getClientIdDomainName() { public String getClientIdServiceName() { return clientIdServiceName; } + + public boolean setCustomClaim(final String name, final Object value) { + + // first verify that the custom claim is not one of the standard claims + + if (isStandardClaim(name)) { + return false; + } + + // create the custom claims map if necessary + + if (customClaims == null) { + customClaims = new java.util.HashMap<>(); + } + customClaims.put(name, value); + return true; + } + + public Object getClaim(final String name) { + return claimsSet.getClaim(name); + } + + /** + * Check if the given claim name is one of the standard claims + * in an OAuth2 token that is already handled separately. + * + * @param claimName claim name + * @return true if standard claim, false otherwise + */ + public boolean isStandardClaim(final String claimName) { + switch (claimName) { + case CLAIM_ISSUER: + case CLAIM_SUBJECT: + case CLAIM_AUDIENCE: + case CLAIM_EXPIRY: + case CLAIM_ISSUE_TIME: + case CLAIM_NOT_BEFORE: + case CLAIM_JWT_ID: + case CLAIM_VERSION: + case CLAIM_AUTH_TIME: + return true; + default: + return false; + } + } } diff --git a/libs/java/auth_core/src/test/java/com/yahoo/athenz/auth/token/AccessTokenTest.java b/libs/java/auth_core/src/test/java/com/yahoo/athenz/auth/token/AccessTokenTest.java index 20b20172bd3..f8ccef24b67 100644 --- a/libs/java/auth_core/src/test/java/com/yahoo/athenz/auth/token/AccessTokenTest.java +++ b/libs/java/auth_core/src/test/java/com/yahoo/athenz/auth/token/AccessTokenTest.java @@ -194,6 +194,55 @@ public void testAccessToken() throws JOSEException, ParseException { assertEquals(scopes.get(0), "readers"); } + @Test + public void testAccessTokenWtihCustomClaims() throws JOSEException, ParseException { + + long now = System.currentTimeMillis() / 1000; + + AccessToken accessToken = createAccessToken(now); + + // custom claims should return true + assertTrue(accessToken.setCustomClaim("preferred_email", "noreply@athenz.io")); + String[] emails = new String[] {"noreply1@athenz.io", "noreply2@athenz.io"}; + assertTrue(accessToken.setCustomClaim("emails", emails)); + + // standard claims should return failure + + assertFalse(accessToken.setCustomClaim(AccessToken.CLAIM_SCOPE, "admins")); + assertFalse(accessToken.setCustomClaim(AccessToken.CLAIM_SUBJECT, "subject")); + + // verify the getters + + validateAccessToken(accessToken, now); + + // now get the signed token + + PrivateKey privateKey = Crypto.loadPrivateKey(ecPrivateKey); + String accessJws = accessToken.getSignedToken(privateKey, "eckey1", "ES256"); + assertNotNull(accessJws); + + // now verify our signed token + + PublicKey publicKey = Crypto.loadPublicKey(ecPublicKey); + JWSVerifier verifier = new ECDSAVerifier((ECPublicKey) publicKey); + SignedJWT signedJWT = SignedJWT.parse(accessJws); + assertTrue(signedJWT.verify(verifier)); + JWTClaimsSet claimsSet = signedJWT.getJWTClaimsSet(); + assertNotNull(claimsSet); + + assertEquals(claimsSet.getSubject(), "subject"); + assertEquals(JwtsHelper.getAudience(claimsSet), "coretech"); + assertEquals(claimsSet.getIssuer(), "athenz"); + assertEquals(claimsSet.getJWTID(), "jwt-id001"); + assertEquals(claimsSet.getStringClaim("scope"), "readers"); + List scopes = claimsSet.getStringListClaim("scp"); + assertNotNull(scopes); + assertEquals(scopes.size(), 1); + assertEquals(scopes.get(0), "readers"); + assertEquals(claimsSet.getClaim("preferred_email"), "noreply@athenz.io"); + assertEquals(claimsSet.getClaim("emails"), Arrays.asList(emails)); + } + @Test public void testAccessTokenMultipleRoles() throws JOSEException, ParseException { @@ -255,6 +304,7 @@ public void testAccessTokenWithX509Cert() throws IOException { X509Certificate cert = Crypto.loadX509Certificate(certStr); AccessToken checkToken = new AccessToken(accessJws, resolver, cert); + assertEquals(checkToken.getClaim("sub"), "subject"); validateAccessToken(checkToken, now); } diff --git a/libs/java/auth_core/src/test/java/com/yahoo/athenz/auth/token/IdTokenTest.java b/libs/java/auth_core/src/test/java/com/yahoo/athenz/auth/token/IdTokenTest.java index d7bd2f38e38..1fffe3ac1dd 100644 --- a/libs/java/auth_core/src/test/java/com/yahoo/athenz/auth/token/IdTokenTest.java +++ b/libs/java/auth_core/src/test/java/com/yahoo/athenz/auth/token/IdTokenTest.java @@ -35,6 +35,7 @@ import java.security.interfaces.ECPublicKey; import java.text.ParseException; import java.time.Instant; +import java.util.Arrays; import java.util.Collections; import java.util.Date; import java.util.Objects; @@ -107,6 +108,49 @@ public void testIdToken() throws JOSEException, ParseException { assertEquals(claimsSet.getIssuer(), "athenz"); } + @Test + public void testIdTokenCustomClaims() throws JOSEException, ParseException { + + long now = System.currentTimeMillis() / 1000; + + IdToken token = createIdToken(now); + + // custom claims should return true + assertTrue(token.setCustomClaim("preferred_email", "noreply@athenz.io")); + String[] emails = new String[] {"noreply1@athenz.io", "noreply2@athenz.io"}; + assertTrue(token.setCustomClaim("emails", emails)); + + // standard claims should return failure + + assertFalse(token.setCustomClaim(IdToken.CLAIM_NONCE, "nonce")); + assertFalse(token.setCustomClaim(IdToken.CLAIM_SUBJECT, "subject")); + + // verify the getters + + validateIdToken(token, now); + + // now get the signed token + + PrivateKey privateKey = Crypto.loadPrivateKey(ecPrivateKey); + String idJws = token.getSignedToken(privateKey, "eckey1", "ES256"); + assertNotNull(idJws); + + // now verify our signed token + + PublicKey publicKey = Crypto.loadPublicKey(ecPublicKey); + JWSVerifier verifier = new ECDSAVerifier((ECPublicKey) publicKey); + SignedJWT signedJWT = SignedJWT.parse(idJws); + assertTrue(signedJWT.verify(verifier)); + JWTClaimsSet claimsSet = signedJWT.getJWTClaimsSet(); + assertNotNull(claimsSet); + + assertEquals(claimsSet.getSubject(), "subject"); + assertEquals(claimsSet.getAudience().get(0), "coretech"); + assertEquals(claimsSet.getIssuer(), "athenz"); + assertEquals(claimsSet.getClaim("preferred_email"), "noreply@athenz.io"); + assertEquals(claimsSet.getClaim("emails"), Arrays.asList(emails)); + } + @Test public void testIdTokenSignedToken() { diff --git a/servers/zts/conf/zts.properties b/servers/zts/conf/zts.properties index 7e24ff8c7c3..1d7af9c9b1a 100644 --- a/servers/zts/conf/zts.properties +++ b/servers/zts/conf/zts.properties @@ -632,14 +632,15 @@ athenz.zts.cert_signer_factory_class=com.yahoo.athenz.zts.cert.impl.SelfCertSign # The uri must not contain a trailing /. #athenz.zts.oidc_port_issuer= -# Comma separated list of trusted external JWT issuers that ZTS will trust -# for JWT Authorization Grant (JAG) tokens. Each issuer entry is specified in the format: -# [||] where: -# - issuer_url: The issuer URL to trust (required) -# - default_jwks_uri: Default JWKS URI to use if not found in openid-configuration (optional) -# - proxy_url: Proxy URL to use when fetching JWKS (optional) -# Multiple issuers can be specified separated by commas. -#athenz.zts.openid_jag_issuers= +# Path to a file that contains the support list of external Identity +# Provider JWT issuers that ZTS will trust for JWT Authorization +# Grant (JAG) tokens. The file must list of provider config objects +# where each object has the following fields: +# - issuerUri: The issuer URI for the provider +# - jwksUri: The JWKS URI for the provider (as default in case we can't extract from issuerUri) +# - proxyUrl: Optional proxy URL for the provider +# - providerClassName: The class name of the TokenExchangeIdentityProvider implementation +#athenz.zts.oauth_provider_config_file= # The path to the trust store file that contains CA certificates # trusted by the ZTS Provider Client (this client is used to validate diff --git a/servers/zts/src/main/java/com/yahoo/athenz/zts/ZTSConsts.java b/servers/zts/src/main/java/com/yahoo/athenz/zts/ZTSConsts.java index 0acfad1aaa2..6274528f4f8 100644 --- a/servers/zts/src/main/java/com/yahoo/athenz/zts/ZTSConsts.java +++ b/servers/zts/src/main/java/com/yahoo/athenz/zts/ZTSConsts.java @@ -74,7 +74,7 @@ public final class ZTSConsts { public static final String ZTS_PROP_OIDC_PORT_ISSUER = "athenz.zts.oidc_port_issuer"; public static final String ZTS_PROP_REDIRECT_URI_SUFFIX = "athenz.zts.redirect_uri_suffix"; public static final String ZTS_PROP_SCOPE_ROLE_WOUT_DOMAIN = "athenz.zts.oauth_scope_role_without_domain"; - public static final String ZTS_PROP_OPENID_JAG_ISSUERS = "athenz.zts.openid_jag_issuers"; + public static final String ZTS_PROP_PROVIDER_CONFIG_FILE = "athenz.zts.oauth_provider_config_file"; public static final String ZTS_PROP_CERTSIGN_BASE_URI = "athenz.zts.certsign_base_uri"; public static final String ZTS_PROP_CERTSIGN_REQUEST_TIMEOUT = "athenz.zts.certsign_request_timeout"; diff --git a/servers/zts/src/main/java/com/yahoo/athenz/zts/ZTSImpl.java b/servers/zts/src/main/java/com/yahoo/athenz/zts/ZTSImpl.java index 374298515c7..20bc3f9012e 100644 --- a/servers/zts/src/main/java/com/yahoo/athenz/zts/ZTSImpl.java +++ b/servers/zts/src/main/java/com/yahoo/athenz/zts/ZTSImpl.java @@ -74,10 +74,7 @@ import com.yahoo.athenz.zts.notification.ZTSNotificationTaskFactory; import com.yahoo.athenz.zts.store.CloudStore; import com.yahoo.athenz.zts.store.DataStore; -import com.yahoo.athenz.zts.token.AccessTokenRequest; -import com.yahoo.athenz.zts.token.AccessTokenScope; -import com.yahoo.athenz.zts.token.IdTokenScope; -import com.yahoo.athenz.zts.token.TokenConfigOptions; +import com.yahoo.athenz.zts.token.*; import com.yahoo.athenz.zts.transportrules.TransportRulesProcessor; import com.yahoo.athenz.zts.utils.ZTSUtils; import com.yahoo.rdl.*; @@ -194,6 +191,7 @@ public class ZTSImpl implements ZTSHandler { protected String serviceCredsEncryptionAlgorithm = null; protected boolean jwtCurveRfcSupportOnly = false; protected TokenConfigOptions tokenConfigOptions = null; + protected ProviderConfigManager providerConfigManager; private static final String TYPE_DOMAIN_NAME = "DomainName"; private static final String TYPE_SIMPLE_NAME = "SimpleName"; @@ -402,19 +400,20 @@ public ZTSImpl(CloudStore implCloudStore, DataStore implDataStore) { spiffeUriManager = new SpiffeUriManager(); - // create our jwt process objects and the config for validating - // access token requests + // create our external provider config manager - generateTokenConfigOptions(); + loadExternalProviderConfigManager(); } - void generateTokenConfigOptions() { + void loadExternalProviderConfigManager() { - List jwtsResolvers = generateSupportedJAGIssuers(); + providerConfigManager = new ProviderConfigManager(System.getProperty(ZTSConsts.ZTS_PROP_PROVIDER_CONFIG_FILE)); // we're always going to add our own zts server as the last entry // in case our config files are not updated thus we need to extract // the public keys from ourselves directly + + List jwtsResolvers = providerConfigManager.getJwtsResolvers(); jwtsResolvers.add(new JwtsResolver(ztsOpenIDIssuer + "/oauth2/keys?rfc=true", null, null)); // create our token config options @@ -506,72 +505,6 @@ private void setupMetaConfigObjects() { oauthConfig.setToken_endpoint_auth_signing_alg_values_supported(getSupportedSigningAlgValues()); } - List generateSupportedJAGIssuers() { - - // extract jag issuers if configured, the format is a comma separated - // list of openid issuers with their default jwks_uri and proxy urls. - // We'll use the default jwks_uri if we're not able to extract the value - // from the openid-configuration endpoint - - final String jagIssuers = System.getProperty(ZTSConsts.ZTS_PROP_OPENID_JAG_ISSUERS); - List jwtsResolvers = new ArrayList<>(); - - if (StringUtil.isEmpty(jagIssuers)) { - return jwtsResolvers; - } - - // the format for the jagIssuers is: - // [||][,[||]]... - - String[] issuersArray = jagIssuers.split(","); - for (String issuer : issuersArray) { - - // extract the default jwks_uri if configured - - String[] comps = issuer.split("\\|"); - if (comps.length == 0 || comps.length > 3) { - LOGGER.error("Invalid jag issuer format: {}", issuer); - continue; - } - - String proxyUrl = null; - String defaultJwksUri = null; - - String issuerUri = comps[0]; - if (comps.length > 1) { - defaultJwksUri = comps[1]; - } - if (comps.length > 2) { - proxyUrl = comps[2]; - } - - if (StringUtil.isEmpty(issuerUri) && StringUtil.isEmpty(defaultJwksUri)) { - LOGGER.error("Invalid jag issuer format: {}", issuer); - continue; - } - - // extract the jwks_uri from the openid-configuration endpoint - - JwtsHelper helper = new JwtsHelper(); - - String jwksUri = helper.extractJwksUri(issuer + "/.well-known/openid-configuration", null, proxyUrl); - if (StringUtil.isEmpty(jwksUri)) { - jwksUri = defaultJwksUri; - } - - if (StringUtil.isEmpty(jwksUri)) { - LOGGER.error("Unable to extract jwks_uri for issuer: {}", issuer); - continue; - } - - // create our resolver object - - jwtsResolvers.add(new JwtsResolver(jwksUri, proxyUrl, null)); - } - - return jwtsResolvers; - } - List getSupportedSigningAlgValues() { List algValues = new ArrayList<>(); @@ -2639,9 +2572,9 @@ public AccessTokenResponse postAccessTokenRequest(ResourceContext ctx, String re switch (accessTokenRequest.getRequestType()) { case JAG_TOKEN_EXCHANGE: - return processAccessTokenJAGExchange(ctx, principal, accessTokenRequest, principalDomain, caller); + return processJAGTokenIssueRequest(ctx, principal, accessTokenRequest, principalDomain, caller); case JAG_JWT_BEARER: - return processAccessTokenJAGRequest(ctx, accessTokenRequest, principal.getFullName(), + return processJAGTokenExchangeRequest(ctx, accessTokenRequest, principal.getFullName(), principalDomain, caller); case TOKEN_EXCHANGE: return processAccessTokenExchangeRequest(ctx, principal, accessTokenRequest, principalDomain, caller); @@ -2656,8 +2589,8 @@ AccessTokenResponse processAccessTokenExchangeRequest(ResourceContext ctx, Princ throw requestError("Not Yet implemented", caller, ZTSConsts.ZTS_UNKNOWN_DOMAIN, principalDomain); } - AccessTokenResponse processAccessTokenJAGExchange(ResourceContext ctx, Principal principal, - AccessTokenRequest accessTokenRequest, final String principalDomain, final String caller) { + AccessTokenResponse processJAGTokenIssueRequest(ResourceContext ctx, Principal principal, + AccessTokenRequest accessTokenRequest, final String principalDomain, final String caller) { // get our principal name for simpler access @@ -2670,16 +2603,25 @@ AccessTokenResponse processAccessTokenJAGExchange(ResourceContext ctx, Principal OAuth2Token subjectToken = accessTokenRequest.getSubjectTokenObj(); // the audience of the subject token must be our client id which - // in our case is the principal name + // in our case is the principal name. However, if the token was issued + // by an external identity provider, it will be issued to the client id + // which should be configured as the client id for our service + TokenExchangeIdentityProvider identityProvider = providerConfigManager.getProvider(subjectToken.getIssuer()); if (!principalName.equals(subjectToken.getAudience())) { - LOGGER.error("The subject token does not have expected audience: {}/{}", principalName, - subjectToken.getAudience()); - throw requestError("Invalid subject token audience", caller, ZTSConsts.ZTS_UNKNOWN_DOMAIN, principalDomain); + final String clientId = dataStore.getServiceClientId(principal.getDomain(), principalName); + final String tokenAudience = identityProvider == null ? subjectToken.getAudience() : + identityProvider.getTokenAudience(subjectToken); + if (clientId == null || !clientId.equals(tokenAudience)) { + LOGGER.error("The subject token does not have expected audience: {}/{}", principalName, + tokenAudience); + throw requestError("Invalid subject token audience", caller, ZTSConsts.ZTS_UNKNOWN_DOMAIN, + principalDomain); + } } if (LOGGER.isDebugEnabled()) { - LOGGER.debug("processAccessTokenJAGExchange(principal: {}, scope: {}, client: {}", principalName, + LOGGER.debug("processJAGTokenIssueRequest(principal: {}, scope: {}, client: {}", principalName, accessTokenRequest.getScope(), subjectToken.getSubject()); } @@ -2711,13 +2653,23 @@ AccessTokenResponse processAccessTokenJAGExchange(ResourceContext ctx, Principal throw notFoundError("No such domain: " + domainName, caller, ZTSConsts.ZTS_UNKNOWN_DOMAIN, principalDomain); } + // if the token was issued by ZTS then the identity is the subject + // otherwise, we need to extract the identity from the token + // using our external provider identity class if one is configured + + final String subjectIdentity = (identityProvider == null) ? subjectToken.getSubject() : + identityProvider.getTokenIdentity(subjectToken); + if (StringUtil.isEmpty(subjectIdentity)) { + LOGGER.error("processJAGTokenIssueRequest: unable to extract subject identity from token"); + throw requestError("Invalid subject token - missing subject", caller, domainName, principalDomain); + } Set subjectRoles = new HashSet<>(); - dataStore.getAccessibleRoles(data, domainName, subjectToken.getSubject(), requestedRoles, false, subjectRoles, false); + dataStore.getAccessibleRoles(data, domainName, subjectIdentity, requestedRoles, false, subjectRoles, false); // we return failure if we don't have access to all the roles requested if (subjectRoles.size() != requestedRoles.length) { - throw forbiddenError(tokenErrorMessage(caller, subjectToken.getSubject(), domainName, requestedRoles), + throw forbiddenError(tokenErrorMessage(caller, subjectIdentity, domainName, requestedRoles), caller, domainName, principalDomain); } @@ -2727,7 +2679,7 @@ AccessTokenResponse processAccessTokenJAGExchange(ResourceContext ctx, Principal for (String requestedRole : requestedRoles) { if (!authorizer.access(ZTSConsts.ZTS_ACTION_JAG_EXCHANGE, ResourceUtils.roleResourceName(domainName, requestedRole), principal, null)) { - LOGGER.error("processAccessTokenJAGExchange: access check failure for {} - {}:role.{}", + LOGGER.error("processJAGTokenIssueRequest: access check failure for {} - {}:role.{}", principalName, domainName, requestedRole); throw forbiddenError("Principal not authorized for token exchange for the requested role", caller, domainName, principalDomain); @@ -2747,16 +2699,24 @@ AccessTokenResponse processAccessTokenJAGExchange(ResourceContext ctx, Principal AccessToken accessToken = new AccessToken(); accessToken.setVersion(1); accessToken.setJwtId(UUID.randomUUID().toString()); - accessToken.setAudience(ztsOpenIDIssuer); + accessToken.setAudience(accessTokenRequest.getAudience()); accessToken.setClientId(principalName); accessToken.setIssueTime(iat); accessToken.setAuthTime(iat); accessToken.setExpiryTime(iat + tokenTimeout); - accessToken.setSubject(subjectToken.getSubject()); + accessToken.setSubject(subjectIdentity); accessToken.setIssuer(ztsOpenIDIssuer); accessToken.setScope(roleList); accessToken.setResource(accessTokenRequest.getResource()); + // include any exchange claims if configured for the provider + + if (identityProvider != null && identityProvider.getTokenExchangeClaims() != null) { + for (String claim : identityProvider.getTokenExchangeClaims()) { + accessToken.setCustomClaim(claim, subjectToken.getClaim(claim)); + } + } + ServerPrivateKey privateKey = getServerPrivateKey(keyAlgoForJsonWebObjects); String accessJwts = accessToken.getSignedToken(privateKey.getKey(), privateKey.getId(), privateKey.getAlgorithm(), AccessToken.HDR_TOKEN_JAG); @@ -2766,7 +2726,7 @@ AccessTokenResponse processAccessTokenJAGExchange(ResourceContext ctx, Principal .setScope(String.join(" ", roleList)); } - AccessTokenResponse processAccessTokenJAGRequest(ResourceContext ctx, AccessTokenRequest accessTokenRequest, + AccessTokenResponse processJAGTokenExchangeRequest(ResourceContext ctx, AccessTokenRequest accessTokenRequest, final String clientPrincipalName, final String clientPrincipalDomain, final String caller) { // our jag token is required for jag token requests and has been validated @@ -2784,11 +2744,22 @@ AccessTokenResponse processAccessTokenJAGRequest(ResourceContext ctx, AccessToke } // finally we need to validate that the client_id claim MUST identify - // the same client as the client authentication in the request. + // the same client as the client authentication in the request. If the jag + // token is issued from a different Identity Provider, then the client_id + // would be the client identifier assigned by that IdP. However, in our + // case the client is authenticated directly by Athenz so we need to + // also check if the client id registered for that service matches + // the client id in the jag token if (!clientPrincipalName.equals(jagToken.getClientId())) { - LOGGER.error("Invalid jag assertion client_id claim: {}", jagToken.getClientId()); - throw requestError("Invalid jag assertion client_id", caller, ZTSConsts.ZTS_UNKNOWN_DOMAIN, clientPrincipalDomain); + + // extract the client-id for the service if one is defined + + final String clientId = dataStore.getServiceClientId(clientPrincipalDomain, clientPrincipalName); + if (clientId == null || !clientId.equals(jagToken.getClientId())) { + LOGGER.error("Invalid jag assertion client_id claim: {}", jagToken.getClientId()); + throw requestError("Invalid jag assertion client_id", caller, ZTSConsts.ZTS_UNKNOWN_DOMAIN, clientPrincipalDomain); + } } // now we need to validate the scope value @@ -2802,7 +2773,9 @@ AccessTokenResponse processAccessTokenJAGRequest(ResourceContext ctx, AccessToke // as the subject in the token and not the principal who was authenticated // to make the request (that is the client id) - String principalName = jagToken.getSubject(); + TokenExchangeIdentityProvider identityProvider = providerConfigManager.getProvider(jagToken.getIssuer()); + String principalName = (identityProvider == null) ? jagToken.getSubject() : + identityProvider.getTokenIdentity(jagToken); if (StringUtil.isEmpty(principalName)) { LOGGER.error("Invalid jag assertion - missing subject"); throw requestError("Invalid jag assertion - missing subject", caller, ZTSConsts.ZTS_UNKNOWN_DOMAIN, clientPrincipalDomain); @@ -2872,6 +2845,12 @@ AccessTokenResponse processAccessTokenJAGRequest(ResourceContext ctx, AccessToke accessToken.setIssuer(jagAudience); accessToken.setScope(new ArrayList<>(roles)); + if (identityProvider != null && identityProvider.getTokenExchangeClaims() != null) { + for (String claim : identityProvider.getTokenExchangeClaims()) { + accessToken.setCustomClaim(claim, jagToken.getClaim(claim)); + } + } + ServerPrivateKey privateKey = getServerPrivateKey(keyAlgoForJsonWebObjects); String accessJwts = accessToken.getSignedToken(privateKey.getKey(), privateKey.getId(), privateKey.getAlgorithm()); @@ -2893,6 +2872,7 @@ AccessTokenResponse processAccessTokenJAGRequest(ResourceContext ctx, AccessToke return response; } + AccessTokenResponse processAccessTokenStandardRequest(ResourceContext ctx, Principal principal, AccessTokenRequest accessTokenRequest, final String principalDomain, final String caller) { diff --git a/servers/zts/src/main/java/com/yahoo/athenz/zts/cache/DataCache.java b/servers/zts/src/main/java/com/yahoo/athenz/zts/cache/DataCache.java index 36e1dbd5b23..c0088432db2 100644 --- a/servers/zts/src/main/java/com/yahoo/athenz/zts/cache/DataCache.java +++ b/servers/zts/src/main/java/com/yahoo/athenz/zts/cache/DataCache.java @@ -51,6 +51,7 @@ public class DataCache { private final Map> awsRoleCache; private final Map publicKeyCache; private final Map svcCredsCache; + private final Map svcClientIdCache; private final Map> providerDnsSuffixCache; private final Map> providerHostnameAllowedSuffixCache; private final Map> providerHostnameDeniedSuffixCache; @@ -78,6 +79,7 @@ public DataCache() { roleMetaCache = new HashMap<>(); publicKeyCache = new HashMap<>(); svcCredsCache = new HashMap<>(); + svcClientIdCache = new HashMap<>(); providerDnsSuffixCache = new HashMap<>(); providerHostnameAllowedSuffixCache = new HashMap<>(); providerHostnameDeniedSuffixCache = new HashMap<>(); @@ -422,6 +424,7 @@ void processServiceIdentityPublicKeys(String serviceName, List p void processServiceIdentityCreds(String serviceName, String creds) { if (StringUtil.isEmpty(creds)) { + svcCredsCache.remove(serviceName); return; } @@ -432,6 +435,20 @@ public String getServiceIdentityCreds(final String serviceName) { return svcCredsCache.get(serviceName); } + void processServiceIdentityClientId(String serviceName, String clientId) { + + if (StringUtil.isEmpty(clientId)) { + svcClientIdCache.remove(serviceName); + return; + } + + svcClientIdCache.put(serviceName, clientId); + } + + public String getServiceIdentityClientId(final String serviceName) { + return svcClientIdCache.get(serviceName); + } + public void processServiceIdentity(com.yahoo.athenz.zms.ServiceIdentity service) { if (LOGGER.isDebugEnabled()) { @@ -446,6 +463,10 @@ public void processServiceIdentity(com.yahoo.athenz.zms.ServiceIdentity service) processServiceIdentityPublicKeys(service.getName(), service.getPublicKeys()); + // next process the client id + + processServiceIdentityClientId(service.getName(), service.getClientId()); + // finally process service creds if enabled processServiceIdentityCreds(service.getName(), service.getCreds()); diff --git a/servers/zts/src/main/java/com/yahoo/athenz/zts/store/DataStore.java b/servers/zts/src/main/java/com/yahoo/athenz/zts/store/DataStore.java index 97acf5dbe22..2ae1734ce9e 100644 --- a/servers/zts/src/main/java/com/yahoo/athenz/zts/store/DataStore.java +++ b/servers/zts/src/main/java/com/yahoo/athenz/zts/store/DataStore.java @@ -1946,6 +1946,17 @@ public PublicKey getServicePublicKey(final String domain, final String service, } } + public String getServiceClientId(final String domainName, final String serviceName) { + + // get the domain object from our cache + + DataCache dataCache = getCacheStore().getIfPresent(domainName); + if (dataCache == null) { + return null; + } + return dataCache.getServiceIdentityClientId(serviceName); + } + public String getPemPublicKey(final String publicKeyName) { String publicKey; diff --git a/servers/zts/src/main/java/com/yahoo/athenz/zts/token/ProviderConfig.java b/servers/zts/src/main/java/com/yahoo/athenz/zts/token/ProviderConfig.java new file mode 100644 index 00000000000..0d85ecea8f2 --- /dev/null +++ b/servers/zts/src/main/java/com/yahoo/athenz/zts/token/ProviderConfig.java @@ -0,0 +1,80 @@ +/* + * Copyright The Athenz Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.yahoo.athenz.zts.token; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Represents a single provider configuration entry from the JSON configuration file. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class ProviderConfig { + + @JsonProperty("issuerUri") + private String issuerUri; + + @JsonProperty("proxyUrl") + private String proxyUrl; + + @JsonProperty("jwksUri") + private String jwksUri; + + @JsonProperty("providerClassName") + private String providerClassName; + + public String getIssuerUri() { + return issuerUri; + } + + public void setIssuerUri(String issuerUri) { + this.issuerUri = issuerUri; + } + + public String getProxyUrl() { + return proxyUrl; + } + + public void setProxyUrl(String proxyUrl) { + this.proxyUrl = proxyUrl; + } + + public String getJwksUri() { + return jwksUri; + } + + public void setJwksUri(String jwksUri) { + this.jwksUri = jwksUri; + } + + public String getProviderClassName() { + return providerClassName; + } + + public void setProviderClassName(String providerClassName) { + this.providerClassName = providerClassName; + } + + @Override + public String toString() { + return "ProviderConfig{" + + "issuerUri='" + issuerUri + '\'' + + ", proxyUrl='" + proxyUrl + '\'' + + ", jwksUri='" + jwksUri + '\'' + + ", providerClassName='" + providerClassName + '\'' + + '}'; + } +} diff --git a/servers/zts/src/main/java/com/yahoo/athenz/zts/token/ProviderConfigManager.java b/servers/zts/src/main/java/com/yahoo/athenz/zts/token/ProviderConfigManager.java new file mode 100644 index 00000000000..4c51bd68974 --- /dev/null +++ b/servers/zts/src/main/java/com/yahoo/athenz/zts/token/ProviderConfigManager.java @@ -0,0 +1,156 @@ +/* + * Copyright The Athenz Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.yahoo.athenz.zts.token; + +import com.yahoo.athenz.auth.TokenExchangeIdentityProvider; +import com.yahoo.athenz.auth.token.jwts.JwtsHelper; +import com.yahoo.athenz.auth.token.jwts.JwtsResolver; +import com.yahoo.rdl.JSON; +import org.eclipse.jetty.util.StringUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; + +/** + * Loads and manages provider configurations from a JSON configuration file. + * The JSON file should contain an array of provider configuration objects, + * where each object has the following fields: + * - issuerUri: The issuer URI for the provider + * - proxyUrl: Optional proxy URL for the provider + * - jwksUri: The JWKS URI for the provider + * - providerClassName: The class name of the provider implementation + * - exchangeClaims: An array of claim names to be exchanged + */ +public class ProviderConfigManager { + + private static final Logger LOGGER = LoggerFactory.getLogger(ProviderConfigManager.class); + + private final Map identityProviders; + private final List jwtsResolvers; + + /** + * Loads provider configurations from a JSON file. + */ + public ProviderConfigManager(String configFilePath) { + + identityProviders = new HashMap<>(); + jwtsResolvers = new ArrayList<>(); + + if (configFilePath == null || configFilePath.isEmpty()) { + LOGGER.info("Provider Manager Configuration file path is not configured. No external providers will be loaded."); + return; + } + + LOGGER.info("Loading provider configurations from file: {}", configFilePath); + + try { + Path path = Paths.get(configFilePath); + byte[] fileBytes = Files.readAllBytes(path); + + // Parse JSON array into List of ProviderConfig objects + + ProviderConfig[] configArray = JSON.fromBytes(fileBytes, ProviderConfig[].class); + + if (configArray == null) { + LOGGER.warn("No provider configurations found in file: {}", configFilePath); + return; + } + + for (ProviderConfig config : configArray) { + processProviderConfig(config); + } + + } catch (IOException ex) { + LOGGER.error("Unable to process provider configuration file: {}", configFilePath, ex); + throw new IllegalArgumentException(ex); + } + } + + void processProviderConfig(ProviderConfig config) { + + if (StringUtil.isEmpty(config.getIssuerUri())) { + LOGGER.error("Issuer Uri is required"); + return; + } + + // extract the jwks_uri from the openid-configuration endpoint + + JwtsHelper helper = new JwtsHelper(); + + String jwksUri = helper.extractJwksUri(config.getIssuerUri() + "/.well-known/openid-configuration", + null, config.getProxyUrl()); + + if (StringUtil.isEmpty(jwksUri)) { + jwksUri = config.getJwksUri(); + } + + if (StringUtil.isEmpty(jwksUri)) { + LOGGER.error("Unable to extract jwks_uri for issuer: {}", config.getIssuerUri()); + return; + } + + if (!StringUtil.isEmpty(config.getProviderClassName())) { + TokenExchangeIdentityProvider identityProvider; + try { + identityProvider = (TokenExchangeIdentityProvider) Class.forName(config.getProviderClassName()) + .getDeclaredConstructor().newInstance(); + } catch (Exception ex) { + LOGGER.error("Invalid TokenExchangeIdentityProvider class: {}", config.getProviderClassName(), ex); + return; + } + identityProviders.put(config.getIssuerUri(), identityProvider); + } + + jwtsResolvers.add(new JwtsResolver(jwksUri, config.getProxyUrl(), null)); + LOGGER.info("Successfully loaded provider config: {}", config.getIssuerUri()); + } + + /** + * Gets the list of loaded jwts resolvers + * + * @return A list of JwtsResolver objects + */ + public List getJwtsResolvers() { + return this.jwtsResolvers; + } + + /** + * Gets a token exchange identity object by issuer URI. + * + * @param issuerUri The issuer URI to search for + * @return The matching TokenExchangeIdentityProvider, or null if not found + */ + public TokenExchangeIdentityProvider getProvider(final String issuerUri) { + return identityProviders.get(issuerUri); + } + + /** + * Puts a token exchange identity provider into the manager. + * Used for unit tests only + * + * @param issuerUri The issuer URI for the provider + * @param provider The TokenExchangeIdentityProvider to add + */ + public void putProvider(final String issuerUri, final TokenExchangeIdentityProvider provider) { + identityProviders.put(issuerUri, provider); + } +} + diff --git a/servers/zts/src/test/java/com/yahoo/athenz/zts/ZTSImplAccessTokenTest.java b/servers/zts/src/test/java/com/yahoo/athenz/zts/ZTSImplAccessTokenTest.java index e8481f70507..e9dc6bbe3ef 100644 --- a/servers/zts/src/test/java/com/yahoo/athenz/zts/ZTSImplAccessTokenTest.java +++ b/servers/zts/src/test/java/com/yahoo/athenz/zts/ZTSImplAccessTokenTest.java @@ -25,6 +25,7 @@ import com.nimbusds.jwt.proc.DefaultJWTProcessor; import com.yahoo.athenz.auth.*; import com.yahoo.athenz.auth.impl.*; +import com.yahoo.athenz.auth.token.OAuth2Token; import com.yahoo.athenz.auth.token.jwts.JwtsHelper; import com.yahoo.athenz.auth.token.jwts.JwtsSigningKeyResolver; import com.yahoo.athenz.auth.util.Crypto; @@ -422,6 +423,7 @@ private List createServices(String domainName, String serviceNa service = new ServiceIdentity(); service.setName(generateServiceIdentityName(domainName, "backup")); + service.setClientId("client-id-001"); setServicePublicKey(service, "0", ZTS_Y64_CERT0); hosts = new ArrayList<>(); @@ -1818,19 +1820,22 @@ public void testPostAccessTokenRequestWithJWTBearerToken() throws JOSEException } private String createJagToken(PrivateKey key, String keyId, String subject, String clientId, - String scope, String audience, long expiryTime) { + String scope, String audience, long expiryTime, String athenzCode) { try { JWSSigner signer = JwtsHelper.getJWSSigner(key); long now = System.currentTimeMillis() / 1000; - JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder() .subject(subject) .issueTime(Date.from(Instant.ofEpochSecond(now))) .expirationTime(Date.from(Instant.ofEpochSecond(expiryTime))) .issuer(clientId) .audience(audience) .claim("client_id", clientId) - .claim("scope", scope) - .build(); + .claim("scope", scope); + if (athenzCode != null) { + builder.claim("athenz_code", athenzCode); + } + JWTClaimsSet claimsSet = builder.build(); SignedJWT signedJWT = new SignedJWT( new JWSHeader.Builder(JWSAlgorithm.ES256) @@ -1845,9 +1850,13 @@ private String createJagToken(PrivateKey key, String keyId, String subject, Stri return null; } } + private String createJagToken(PrivateKey key, String keyId, String subject, String clientId, + String scope, String audience, long expiryTime) { + return createJagToken(key, keyId, subject, clientId, scope, audience, expiryTime, null); + } @Test - public void testProcessAccessTokenJAGRequestSuccess() throws JOSEException { + public void testProcessJAGTokenExchangeRequestSuccess() throws JOSEException { System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_at_private.pem"); @@ -1901,7 +1910,84 @@ public void testProcessAccessTokenJAGRequestSuccess() throws JOSEException { } @Test - public void testProcessAccessTokenJAGRequestSuccessWithOpenIDIssuer() throws JOSEException { + public void testProcessJAGTokenExchangeRequestSuccessExtraClaims() throws JOSEException { + + System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_at_private.pem"); + System.setProperty(ZTSConsts.ZTS_PROP_PROVIDER_CONFIG_FILE, "src/test/resources/provider.config.json"); + + TokenExchangeIdentityProvider provider = new TokenExchangeIdentityProvider() { + @Override + public String getTokenIdentity(OAuth2Token token) { + return "user_domain.user"; + } + + @Override + public String getTokenAudience(OAuth2Token token) { + return token.getAudience(); + } + + @Override + public List getTokenExchangeClaims() { + return List.of("preferred_email", "athenz_code"); + } + }; + + CloudStore cloudStore = new CloudStore(); + ZTSImpl ztsImpl = new ZTSImpl(cloudStore, store); + ztsImpl.providerConfigManager.putProvider("coretech.jwt", provider); + ztsImpl.tokenConfigOptions.setJwtJAGProcessor(createJAGProcessor()); + System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_private.pem"); + + SignedDomain signedDomain = createSignedDomain("coretech", "weather", "storage", true); + store.processSignedDomain(signedDomain, false); + + // Create JAG token + File privateKeyFile = new File("src/test/resources/unit_test_zts_private_ec.pem"); + PrivateKey privateKey = Crypto.loadPrivateKey(privateKeyFile); + long expiryTime = System.currentTimeMillis() / 1000 + 3600; + String jagToken = createJagToken(privateKey, "0", "user_domain.user", "coretech.jwt", + "coretech:domain", ztsImpl.ztsOAuthIssuer, expiryTime, "athenz-code"); + + HttpServletRequest servletRequest = Mockito.mock(HttpServletRequest.class); + Mockito.when(servletRequest.isSecure()).thenReturn(true); + Principal principal = SimplePrincipal.create("coretech", "jwt", + "v=U1;d=coretech;n=jwt;s=signature", 0, null); + ResourceContext context = createResourceContext(principal); + + AccessTokenResponse resp = ztsImpl.postAccessTokenRequest(context, + "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=" + jagToken + + "&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + + "&client_assertion=" + createClientAssertionToken(privateKey)); + + assertNotNull(resp); + assertEquals(resp.getScope(), "coretech:role.writers"); + assertNotNull(resp.getAccess_token()); + assertTrue(resp.getExpires_in() > 0); + assertEquals(resp.getToken_type(), "Bearer"); + + // Verify the access token + ServerPrivateKey serverPrivateKey = getServerPrivateKey(ztsImpl, ztsImpl.keyAlgoForJsonWebObjects); + JWSVerifier verifier = JwtsHelper.getJWSVerifier(Crypto.extractPublicKey(serverPrivateKey.getKey())); + try { + SignedJWT signedJWT = SignedJWT.parse(resp.getAccess_token()); + assertTrue(signedJWT.verify(verifier)); + + JWTClaimsSet claimSet = signedJWT.getJWTClaimsSet(); + assertEquals(claimSet.getSubject(), "user_domain.user"); + assertEquals(claimSet.getAudience().get(0), "coretech"); + assertEquals(claimSet.getStringClaim("client_id"), "coretech.jwt"); + assertEquals(claimSet.getIssuer(), ztsImpl.ztsOAuthIssuer); + assertEquals(claimSet.getClaim("athenz_code"), "athenz-code"); + } catch (ParseException ex) { + fail(ex.getMessage()); + } + + System.clearProperty(ZTSConsts.ZTS_PROP_PROVIDER_CONFIG_FILE); + } + + + @Test + public void testProcessJAGTokenExchangeRequestSuccessWithOpenIDIssuer() throws JOSEException { System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_at_private.pem"); System.setProperty(ZTSConsts.ZTS_PROP_OPENID_ISSUER, "https://openid.athenz.io:4443/zts/v1"); @@ -1955,7 +2041,7 @@ public void testProcessAccessTokenJAGRequestSuccessWithOpenIDIssuer() throws JOS } @Test - public void testProcessAccessTokenJAGRequestSuccessWithSpecificRoles() { + public void testProcessJAGTokenExchangeRequestSuccessWithSpecificRoles() { System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_at_private.pem"); @@ -1991,7 +2077,7 @@ public void testProcessAccessTokenJAGRequestSuccessWithSpecificRoles() { } @Test - public void testProcessAccessTokenJAGRequestInvalidAssertion() { + public void testProcessJAGTokenExchangeRequestInvalidAssertion() { System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_at_private.pem"); @@ -2025,7 +2111,7 @@ public void testProcessAccessTokenJAGRequestInvalidAssertion() { } @Test - public void testProcessAccessTokenJAGRequestInvalidAudience() { + public void testProcessJAGTokenExchangeRequestInvalidAudience() { System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_at_private.pem"); @@ -2063,7 +2149,7 @@ public void testProcessAccessTokenJAGRequestInvalidAudience() { } @Test - public void testProcessAccessTokenJAGRequestInvalidClientId() { + public void testProcessJAGTokenExchangeRequestInvalidClientId() { System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_at_private.pem"); @@ -2101,7 +2187,7 @@ public void testProcessAccessTokenJAGRequestInvalidClientId() { } @Test - public void testProcessAccessTokenJAGRequestMissingScope() { + public void testProcessJAGTokenExchangeRequestMissingScope() { System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_at_private.pem"); @@ -2139,7 +2225,7 @@ public void testProcessAccessTokenJAGRequestMissingScope() { } @Test - public void testProcessAccessTokenJAGRequestMissingSubject() { + public void testProcessJAGTokenExchangeRequestMissingSubject() { System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_at_private.pem"); @@ -2177,7 +2263,7 @@ public void testProcessAccessTokenJAGRequestMissingSubject() { } @Test - public void testProcessAccessTokenJAGRequestInvalidDomain() { + public void testProcessJAGTokenExchangeRequestInvalidDomain() { System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_at_private.pem"); @@ -2214,7 +2300,7 @@ public void testProcessAccessTokenJAGRequestInvalidDomain() { } @Test - public void testProcessAccessTokenJAGRequestDomainNotFound() { + public void testProcessJAGTokenExchangeRequestDomainNotFound() { System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_at_private.pem"); @@ -2253,7 +2339,7 @@ public void testProcessAccessTokenJAGRequestDomainNotFound() { } @Test - public void testProcessAccessTokenJAGRequestNoAccessibleRoles() { + public void testProcessJAGTokenExchangeRequestNoAccessibleRoles() { System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_at_private.pem"); @@ -2290,7 +2376,7 @@ public void testProcessAccessTokenJAGRequestNoAccessibleRoles() { } @Test - public void testProcessAccessTokenJAGRequestMultipleRolesScopeResponse() { + public void testProcessJAGTokenExchangeRequestMultipleRolesScopeResponse() { System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_at_private.pem"); @@ -2328,7 +2414,7 @@ public void testProcessAccessTokenJAGRequestMultipleRolesScopeResponse() { } @Test - public void testProcessAccessTokenJAGRequestRoleMismatch() { + public void testProcessJAGTokenExchangeRequestRoleMismatch() { System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_at_private.pem"); @@ -2386,21 +2472,26 @@ private String createClientAssertionToken(PrivateKey privateKey) { } } - private String createIdToken(PrivateKey privateKey, String keyId, String subject, - String audience, long expiryTime) { + private String createIdToken(PrivateKey privateKey, String keyId, String subject, + String audience, long expiryTime, String preferredEmail, String athenzCode) { try { JWSSigner signer = JwtsHelper.getJWSSigner(privateKey); long now = System.currentTimeMillis() / 1000; - JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder() .subject(subject) .issueTime(Date.from(Instant.ofEpochSecond(now))) .expirationTime(Date.from(Instant.ofEpochSecond(expiryTime))) .issuer("https://athenz.io:4443/zts/v1") .audience(audience) .claim("ver", 1) - .claim("auth_time", now) - .build(); - + .claim("auth_time", now); + if (preferredEmail != null) { + builder.claim("preferred_email", preferredEmail); + } + if (athenzCode != null) { + builder.claim("athenz_code", athenzCode); + } + JWTClaimsSet claimsSet = builder.build(); SignedJWT signedJWT = new SignedJWT( new JWSHeader.Builder(JWSAlgorithm.ES256) .keyID(keyId) @@ -2413,6 +2504,10 @@ private String createIdToken(PrivateKey privateKey, String keyId, String subject return null; } } + private String createIdToken(PrivateKey privateKey, String keyId, String subject, + String audience, long expiryTime) { + return createIdToken(privateKey, keyId, subject, audience, expiryTime, null, null); + } private ConfigurableJWTProcessor createIDTokenProcessor() { final String jwksUri = Objects.requireNonNull(classLoader.getResource("jwt_jwks.json")).toString(); @@ -2425,7 +2520,7 @@ private ConfigurableJWTProcessor createIDTokenProcessor() { } @Test - public void testProcessAccessTokenJAGExchangeSuccess() throws JOSEException { + public void testProcessJAGTokenIssueRequestSuccess() throws JOSEException { System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_at_private.pem"); CloudStore cloudStore = new CloudStore(); @@ -2484,7 +2579,7 @@ public void testProcessAccessTokenJAGExchangeSuccess() throws JOSEException { assertNotNull(claimSet); assertNotNull(claimSet.getJWTID()); assertEquals(claimSet.getSubject(), "user_domain.user"); - assertEquals(claimSet.getAudience().get(0), ztsImpl.ztsOpenIDIssuer); + assertEquals(claimSet.getAudience().get(0), "https://athenz.io"); assertEquals(claimSet.getIssuer(), ztsImpl.ztsOpenIDIssuer); assertEquals(claimSet.getStringClaim("client_id"), "user_domain.proxy-user1"); @@ -2503,7 +2598,7 @@ public void testProcessAccessTokenJAGExchangeSuccess() throws JOSEException { } @Test - public void testProcessAccessTokenJAGExchangeScopeMissingRoles() { + public void testProcessJAGTokenIssueRequestScopeMissingRoles() { System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_at_private.pem"); CloudStore cloudStore = new CloudStore(); @@ -2554,7 +2649,7 @@ public void testProcessAccessTokenJAGExchangeScopeMissingRoles() { } @Test - public void testProcessAccessTokenJAGExchangeNotAuthorized() { + public void testProcessJAGTokenIssueRequestNotAuthorized() { System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_at_private.pem"); CloudStore cloudStore = new CloudStore(); @@ -2611,7 +2706,7 @@ public void testProcessAccessTokenJAGExchangeNotAuthorized() { } @Test - public void testProcessAccessTokenJAGExchangeInvalidSubjectToken() { + public void testProcessJAGTokenIssueRequestInvalidSubject() { System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_at_private.pem"); CloudStore cloudStore = new CloudStore(); @@ -2640,7 +2735,7 @@ public void testProcessAccessTokenJAGExchangeInvalidSubjectToken() { + "&scope=coretech:role.writers", tokenConfigOptions); - ztsImpl.processAccessTokenJAGExchange(context, principal, + ztsImpl.processJAGTokenIssueRequest(context, principal, accessTokenRequest, "user_domain", "postAccessTokenRequest"); fail("Expected ResourceException for invalid subject token"); } catch (IllegalArgumentException ex) { @@ -2651,7 +2746,7 @@ public void testProcessAccessTokenJAGExchangeInvalidSubjectToken() { } @Test - public void testProcessAccessTokenJAGExchangeWrongAudience() { + public void testProcessJAGTokenIssueRequestWrongAudience() { System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_at_private.pem"); CloudStore cloudStore = new CloudStore(); @@ -2686,7 +2781,7 @@ public void testProcessAccessTokenJAGExchangeWrongAudience() { + "&scope=coretech:role.writers", tokenConfigOptions); - ztsImpl.processAccessTokenJAGExchange(context, principal, + ztsImpl.processJAGTokenIssueRequest(context, principal, accessTokenRequest, "user_domain", "postAccessTokenRequest"); fail("Expected ResourceException for wrong audience"); } catch (ResourceException ex) { @@ -2698,7 +2793,7 @@ public void testProcessAccessTokenJAGExchangeWrongAudience() { } @Test - public void testProcessAccessTokenJAGExchangeEmptyScope() { + public void testProcessJAGTokenIssueRequestEmptyScope() { System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_at_private.pem"); CloudStore cloudStore = new CloudStore(); @@ -2744,7 +2839,7 @@ public void testProcessAccessTokenJAGExchangeEmptyScope() { + "&scope=openid", tokenConfigOptions); - ztsImpl.processAccessTokenJAGExchange(context, principal, + ztsImpl.processJAGTokenIssueRequest(context, principal, accessTokenRequest, "user_domain", "postAccessTokenRequest"); fail("Expected ResourceException for empty scope"); } catch (ResourceException ex) { @@ -2765,7 +2860,7 @@ private TokenConfigOptions createTokenConfigOptions(ZTSImpl ztsImpl) { } @Test - public void testProcessAccessTokenJAGExchangeDomainNotFound() { + public void testProcessJAGTokenIssueRequestDomainNotFound() { System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_at_private.pem"); CloudStore cloudStore = new CloudStore(); @@ -2796,7 +2891,7 @@ public void testProcessAccessTokenJAGExchangeDomainNotFound() { + "&scope=nonexistent:role.writers", tokenConfigOptions); - ztsImpl.processAccessTokenJAGExchange(context, principal, + ztsImpl.processJAGTokenIssueRequest(context, principal, accessTokenRequest, "user_domain", "postAccessTokenRequest"); fail("Expected ResourceException for domain not found"); } catch (ResourceException ex) { @@ -2845,7 +2940,7 @@ public void testProcessAccessTokenJAGExchangeSubjectNoAccess() { + "&scope=coretech:role.writers", tokenConfigOptions); - ztsImpl.processAccessTokenJAGExchange(context, principal, + ztsImpl.processJAGTokenIssueRequest(context, principal, accessTokenRequest, "user_domain", "postAccessTokenRequest"); fail("Expected ResourceException for subject no access"); } catch (ResourceException ex) { @@ -2856,7 +2951,7 @@ public void testProcessAccessTokenJAGExchangeSubjectNoAccess() { } @Test - public void testProcessAccessTokenJAGExchangePrincipalNotAuthorized() { + public void testProcessJAGTokenIssueRequestPrincipalNotAuthorized() { System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_at_private.pem"); CloudStore cloudStore = new CloudStore(); @@ -2892,7 +2987,7 @@ public void testProcessAccessTokenJAGExchangePrincipalNotAuthorized() { + "&scope=coretech:role.writers", tokenConfigOptions); - ztsImpl.processAccessTokenJAGExchange(context, principal, + ztsImpl.processJAGTokenIssueRequest(context, principal, accessTokenRequest, "user_domain", "postAccessTokenRequest"); fail("Expected ResourceException for principal not authorized"); } catch (ResourceException ex) { @@ -2943,7 +3038,7 @@ public void testProcessAccessTokenJAGExchangePartialAccess() { + "&scope=coretech:role.writers coretech:role.readers", tokenConfigOptions); - ztsImpl.processAccessTokenJAGExchange(context, principal, + ztsImpl.processJAGTokenIssueRequest(context, principal, accessTokenRequest, "user_domain", "postAccessTokenRequest"); fail("Expected ResourceException for partial access"); } catch (ResourceException ex) { @@ -2954,7 +3049,7 @@ public void testProcessAccessTokenJAGExchangePartialAccess() { } @Test - public void testProcessAccessTokenJAGExchangeMultipleRoles() throws JOSEException { + public void testProcessJAGTokenIssueRequestMultipleRoles() throws JOSEException { System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_at_private.pem"); CloudStore cloudStore = new CloudStore(); @@ -2991,7 +3086,7 @@ public void testProcessAccessTokenJAGExchangeMultipleRoles() throws JOSEExceptio + "&scope=coretech:role.writers coretech:role.readers", tokenConfigOptions); - AccessTokenResponse response = ztsImpl.processAccessTokenJAGExchange(context, principal, + AccessTokenResponse response = ztsImpl.processJAGTokenIssueRequest(context, principal, accessTokenRequest, "user_domain", "postAccessTokenRequest"); assertNotNull(response); @@ -3020,7 +3115,7 @@ public void testProcessAccessTokenJAGExchangeMultipleRoles() throws JOSEExceptio } @Test - public void testProcessAccessTokenJAGExchangeWithExpiryTime() { + public void testProcessJAGTokenIssueRequestWithExpiryTime() { System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_at_private.pem"); CloudStore cloudStore = new CloudStore(); @@ -3057,7 +3152,7 @@ public void testProcessAccessTokenJAGExchangeWithExpiryTime() { + "&expires_in=600", tokenConfigOptions); - AccessTokenResponse response = ztsImpl.processAccessTokenJAGExchange(context, principal, + AccessTokenResponse response = ztsImpl.processJAGTokenIssueRequest(context, principal, accessTokenRequest, "user_domain", "postAccessTokenRequest"); assertNotNull(response); @@ -3067,7 +3162,7 @@ public void testProcessAccessTokenJAGExchangeWithExpiryTime() { } @Test - public void testProcessAccessTokenJAGExchangeInvalidRoleName() { + public void testProcessJAGTokenIssueRequestInvalidRoleName() { System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_at_private.pem"); CloudStore cloudStore = new CloudStore(); @@ -3102,7 +3197,7 @@ public void testProcessAccessTokenJAGExchangeInvalidRoleName() { + "&scope=coretech:role.invalid@role", tokenConfigOptions); - ztsImpl.processAccessTokenJAGExchange(context, principal, + ztsImpl.processJAGTokenIssueRequest(context, principal, accessTokenRequest, "user_domain", "postAccessTokenRequest"); fail("Expected ResourceException for invalid role name"); } catch (ResourceException ex) { @@ -3113,7 +3208,7 @@ public void testProcessAccessTokenJAGExchangeInvalidRoleName() { } @Test - public void testProcessAccessTokenJAGExchangeInvalidDomainName() { + public void testProcessJAGTokenIssueRequestInvalidDomainName() { System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_at_private.pem"); CloudStore cloudStore = new CloudStore(); @@ -3145,7 +3240,7 @@ public void testProcessAccessTokenJAGExchangeInvalidDomainName() { + "&scope=invalid@domain:role.writers", tokenConfigOptions); - ztsImpl.processAccessTokenJAGExchange(context, principal, + ztsImpl.processJAGTokenIssueRequest(context, principal, accessTokenRequest, "user_domain", "postAccessTokenRequest"); fail("Expected ResourceException for invalid domain name"); } catch (ResourceException ex) { @@ -3287,4 +3382,257 @@ public void testProcessAccessTokenExchangeNotImplemented() { cloudStore.close(); } + + @Test + public void testProcessJAGTokenIssueRequestSuccessWithExternalProvider() throws JOSEException { + + System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_at_private.pem"); + System.setProperty(ZTSConsts.ZTS_PROP_PROVIDER_CONFIG_FILE, "src/test/resources/provider.config.json"); + + TokenExchangeIdentityProvider provider = new TokenExchangeIdentityProvider() { + @Override + public String getTokenIdentity(OAuth2Token token) { + return "user_domain.user"; + } + + @Override + public String getTokenAudience(OAuth2Token token) { + return token.getAudience(); + } + + @Override + public List getTokenExchangeClaims() { + return List.of("preferred_email", "athenz_code"); + } + }; + + CloudStore cloudStore = new CloudStore(); + ZTSImpl ztsImpl = new ZTSImpl(cloudStore, store); + ztsImpl.providerConfigManager.putProvider("https://athenz.io:4443/zts/v1", provider); + ztsImpl.tokenConfigOptions.setJwtIDTProcessor(createIDTokenProcessor()); + + // set back to our zts rsa private key + System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_private.pem"); + + // Create domain with roles and policies + SignedDomain signedDomain = createSignedDomain("coretech", "weather", "storage", true); + store.processSignedDomain(signedDomain, false); + + // Add JAG exchange authorization policy + addJAGExchangePolicy("coretech", "user_domain.proxy-user1", "writers"); + + // Load EC private key for creating tokens + final File ecPrivateKey = new File("./src/test/resources/unit_test_zts_private_ec.pem"); + PrivateKey privateKey = Crypto.loadPrivateKey(ecPrivateKey); + + // Create a subject token for a0001 with audience as proxy-user1 + // a0001 is an external identity mapped to user_domain.user + + long expiryTime = System.currentTimeMillis() / 1000 + 3600; + String subjectToken = createIdToken(privateKey, "0", "a0001", "user_domain.proxy-user1", + expiryTime, "john.doe@athenz.io", "athenz-code"); + + // Create principal for proxy-user1 who will request the token exchange + Principal principal = SimplePrincipal.create("user_domain", "proxy-user1", + "v=U1;d=user_domain;n=proxy-user1;s=signature", 0, null); + assertNotNull(principal); + ResourceContext context = createResourceContext(principal); + + final String tokenRequest = "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" + + "&requested_token_type=urn:ietf:params:oauth:token-type:id-jag" + + "&subject_token=" + subjectToken + "&audience=https://athenz.io" + + "&subject_token_type=urn:ietf:params:oauth:token-type:id_token" + + "&scope=coretech:role.writers"; + + AccessTokenResponse response = ztsImpl.postAccessTokenRequest(context, tokenRequest); + + assertNotNull(response); + assertNotNull(response.getAccess_token()); + assertEquals(response.getToken_type(), "N_A"); + assertEquals(response.getIssued_token_type(), "urn:ietf:params:oauth:token-type:id-jag"); + assertTrue(response.getExpires_in() > 0); + + // Verify the access token claims + String accessTokenStr = response.getAccess_token(); + ServerPrivateKey serverPrivateKey = getServerPrivateKey(ztsImpl, ztsImpl.keyAlgoForJsonWebObjects); + JWSVerifier verifier = JwtsHelper.getJWSVerifier(Crypto.extractPublicKey(serverPrivateKey.getKey())); + + try { + SignedJWT signedJWT = SignedJWT.parse(accessTokenStr); + assertTrue(signedJWT.verify(verifier)); + JWTClaimsSet claimSet = signedJWT.getJWTClaimsSet(); + + assertNotNull(claimSet); + assertNotNull(claimSet.getJWTID()); + assertEquals(claimSet.getSubject(), "user_domain.user"); + assertEquals(claimSet.getAudience().get(0), "https://athenz.io"); + assertEquals(claimSet.getIssuer(), ztsImpl.ztsOpenIDIssuer); + assertEquals(claimSet.getStringClaim("client_id"), "user_domain.proxy-user1"); + assertEquals(claimSet.getStringClaim("preferred_email"), "john.doe@athenz.io"); + assertEquals(claimSet.getStringClaim("athenz_code"), "athenz-code"); + + List scopes = claimSet.getStringListClaim("scp"); + assertNotNull(scopes); + assertEquals(scopes.size(), 1); + assertEquals(scopes.get(0), "coretech:role.writers"); + + // Check that the token type header is JAG + assertEquals(signedJWT.getHeader().getType().toString(), "oauth-id-jag+jwt"); + } catch (Exception ex) { + fail(ex.getMessage()); + } + + cloudStore.close(); + + System.clearProperty(ZTSConsts.ZTS_PROP_PROVIDER_CONFIG_FILE); + } + + @Test + public void testProcessJAGTokenIssueRequestFailureWithExternalProvider() throws JOSEException { + + System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_at_private.pem"); + + TokenExchangeIdentityProvider provider = new TokenExchangeIdentityProvider() { + @Override + public String getTokenIdentity(OAuth2Token token) { + return null; + } + + @Override + public String getTokenAudience(OAuth2Token token) { + return token.getAudience(); + } + + @Override + public List getTokenExchangeClaims() { + return Collections.emptyList(); + } + }; + + CloudStore cloudStore = new CloudStore(); + ZTSImpl ztsImpl = new ZTSImpl(cloudStore, store); + ztsImpl.providerConfigManager.putProvider("https://athenz.io:4443/zts/v1", provider); + ztsImpl.tokenConfigOptions.setJwtIDTProcessor(createIDTokenProcessor()); + + // set back to our zts rsa private key + System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_private.pem"); + + // Create domain with roles and policies + SignedDomain signedDomain = createSignedDomain("coretech", "weather", "storage", true); + store.processSignedDomain(signedDomain, false); + + // Add JAG exchange authorization policy + addJAGExchangePolicy("coretech", "user_domain.proxy-user1", "writers"); + + // Load EC private key for creating tokens + final File ecPrivateKey = new File("./src/test/resources/unit_test_zts_private_ec.pem"); + PrivateKey privateKey = Crypto.loadPrivateKey(ecPrivateKey); + + // Create a subject token for a0001 with audience as proxy-user1 + // a0001 is an external identity mapped to user_domain.user + + long expiryTime = System.currentTimeMillis() / 1000 + 3600; + String subjectToken = createIdToken(privateKey, "0", "a0001", + "user_domain.proxy-user1", expiryTime); + + // Create principal for proxy-user1 who will request the token exchange + Principal principal = SimplePrincipal.create("user_domain", "proxy-user1", + "v=U1;d=user_domain;n=proxy-user1;s=signature", 0, null); + assertNotNull(principal); + ResourceContext context = createResourceContext(principal); + + final String tokenRequest = "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" + + "&requested_token_type=urn:ietf:params:oauth:token-type:id-jag" + + "&subject_token=" + subjectToken + "&audience=https://athenz.io" + + "&subject_token_type=urn:ietf:params:oauth:token-type:id_token" + + "&scope=coretech:role.writers"; + + try { + ztsImpl.postAccessTokenRequest(context, tokenRequest); + fail("Expected ResourceException for invalid identity from provider"); + } catch (ResourceException ex) { + assertEquals(ex.getCode(), ResourceException.BAD_REQUEST); + assertTrue(ex.getMessage().contains("Invalid subject token - missing subject")); + } + + cloudStore.close(); + } + + @Test + public void testProcessJAGTokenIssueRequestSuccessWithServiceClientId() throws JOSEException { + System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_at_private.pem"); + + CloudStore cloudStore = new CloudStore(); + ZTSImpl ztsImpl = new ZTSImpl(cloudStore, store); + ztsImpl.tokenConfigOptions.setJwtIDTProcessor(createIDTokenProcessor()); + + // set back to our zts rsa private key + System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_private.pem"); + + // Create domain with roles and policies + SignedDomain signedDomain = createSignedDomain("coretech", "weather", "storage", true); + store.processSignedDomain(signedDomain, false); + + // Add JAG exchange authorization policy + addJAGExchangePolicy("coretech", "coretech.backup", "writers"); + + // Load EC private key for creating tokens + final File ecPrivateKey = new File("./src/test/resources/unit_test_zts_private_ec.pem"); + PrivateKey privateKey = Crypto.loadPrivateKey(ecPrivateKey); + + // Create a subject token for user_domain.user with audience as proxy-user1 + long expiryTime = System.currentTimeMillis() / 1000 + 3600; + String subjectToken = createIdToken(privateKey, "0", "user_domain.user", + "client-id-001", expiryTime); + + // Create principal for proxy-user1 who will request the token exchange + Principal principal = SimplePrincipal.create("coretech", "backup", + "v=U1;d=coretech;n=backup;s=signature", 0, null); + assertNotNull(principal); + ResourceContext context = createResourceContext(principal); + + final String tokenRequest = "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" + + "&requested_token_type=urn:ietf:params:oauth:token-type:id-jag" + + "&subject_token=" + subjectToken + "&audience=https://athenz.io" + + "&subject_token_type=urn:ietf:params:oauth:token-type:id_token" + + "&scope=coretech:role.writers"; + + AccessTokenResponse response = ztsImpl.postAccessTokenRequest(context, tokenRequest); + + assertNotNull(response); + assertNotNull(response.getAccess_token()); + assertEquals(response.getToken_type(), "N_A"); + assertEquals(response.getIssued_token_type(), "urn:ietf:params:oauth:token-type:id-jag"); + assertTrue(response.getExpires_in() > 0); + + // Verify the access token claims + String accessTokenStr = response.getAccess_token(); + ServerPrivateKey serverPrivateKey = getServerPrivateKey(ztsImpl, ztsImpl.keyAlgoForJsonWebObjects); + JWSVerifier verifier = JwtsHelper.getJWSVerifier(Crypto.extractPublicKey(serverPrivateKey.getKey())); + + try { + SignedJWT signedJWT = SignedJWT.parse(accessTokenStr); + assertTrue(signedJWT.verify(verifier)); + JWTClaimsSet claimSet = signedJWT.getJWTClaimsSet(); + + assertNotNull(claimSet); + assertNotNull(claimSet.getJWTID()); + assertEquals(claimSet.getSubject(), "user_domain.user"); + assertEquals(claimSet.getAudience().get(0), "https://athenz.io"); + assertEquals(claimSet.getIssuer(), ztsImpl.ztsOpenIDIssuer); + assertEquals(claimSet.getStringClaim("client_id"), "coretech.backup"); + + List scopes = claimSet.getStringListClaim("scp"); + assertNotNull(scopes); + assertEquals(scopes.size(), 1); + assertEquals(scopes.get(0), "coretech:role.writers"); + + // Check that the token type header is JAG + assertEquals(signedJWT.getHeader().getType().toString(), "oauth-id-jag+jwt"); + } catch (Exception ex) { + fail(ex.getMessage()); + } + + cloudStore.close(); + } } diff --git a/servers/zts/src/test/java/com/yahoo/athenz/zts/ZTSImplTest.java b/servers/zts/src/test/java/com/yahoo/athenz/zts/ZTSImplTest.java index bf1bcbf96d8..ffbebe9369e 100644 --- a/servers/zts/src/test/java/com/yahoo/athenz/zts/ZTSImplTest.java +++ b/servers/zts/src/test/java/com/yahoo/athenz/zts/ZTSImplTest.java @@ -14543,200 +14543,4 @@ public void testGetServiceSshKeySignerIdPreferServiceOverDomain() { String result = ztsImpl.getServiceSshKeySignerId(domainData, serviceIdentity, "request-key-id"); assertEquals(result, "service-ssh-key-id"); } - - @Test - public void testGenerateSupportedJAGIssuersEmpty() { - // Test when property is not set - System.clearProperty(ZTSConsts.ZTS_PROP_OPENID_JAG_ISSUERS); - - List resolvers = zts.generateSupportedJAGIssuers(); - - assertNotNull(resolvers); - assertEquals(resolvers.size(), 0); - } - - @Test - public void testGenerateSupportedJAGIssuersEmptyString() { - // Test when property is set to empty string - System.setProperty(ZTSConsts.ZTS_PROP_OPENID_JAG_ISSUERS, ""); - - List resolvers = zts.generateSupportedJAGIssuers(); - - assertNotNull(resolvers); - assertEquals(resolvers.size(), 0); - - System.clearProperty(ZTSConsts.ZTS_PROP_OPENID_JAG_ISSUERS); - } - - @Test - public void testGenerateSupportedJAGIssuersSingleIssuer() { - // Test with a single issuer with default jwks_uri - System.setProperty(ZTSConsts.ZTS_PROP_OPENID_JAG_ISSUERS, - "https://issuer1.example.com|https://issuer1.example.com/jwks"); - - List resolvers = zts.generateSupportedJAGIssuers(); - - assertNotNull(resolvers); - assertEquals(resolvers.size(), 1); - assertEquals(resolvers.get(0).getJwksUri(), "https://issuer1.example.com/jwks"); - assertNull(resolvers.get(0).getProxyUrl()); - assertNull(resolvers.get(0).getSslContext()); - - System.clearProperty(ZTSConsts.ZTS_PROP_OPENID_JAG_ISSUERS); - } - - @Test - public void testGenerateSupportedJAGIssuersMultipleIssuers() { - // Test with multiple issuers - System.setProperty(ZTSConsts.ZTS_PROP_OPENID_JAG_ISSUERS, - "https://issuer1.example.com|https://issuer1.example.com/jwks," + - "https://issuer2.example.com|https://issuer2.example.com/jwks"); - - List resolvers = zts.generateSupportedJAGIssuers(); - - assertNotNull(resolvers); - assertEquals(resolvers.size(), 2); - assertEquals(resolvers.get(0).getJwksUri(), "https://issuer1.example.com/jwks"); - assertEquals(resolvers.get(1).getJwksUri(), "https://issuer2.example.com/jwks"); - - System.clearProperty(ZTSConsts.ZTS_PROP_OPENID_JAG_ISSUERS); - } - - @Test - public void testGenerateSupportedJAGIssuersWithProxyUrl() { - // Test with issuer that has proxy URL - System.setProperty(ZTSConsts.ZTS_PROP_OPENID_JAG_ISSUERS, - "https://issuer1.example.com|https://issuer1.example.com/jwks|https://proxy.example.com"); - - List resolvers = zts.generateSupportedJAGIssuers(); - - assertNotNull(resolvers); - assertEquals(resolvers.size(), 1); - assertEquals(resolvers.get(0).getJwksUri(), "https://issuer1.example.com/jwks"); - assertEquals(resolvers.get(0).getProxyUrl(), "https://proxy.example.com"); - assertNull(resolvers.get(0).getSslContext()); - - System.clearProperty(ZTSConsts.ZTS_PROP_OPENID_JAG_ISSUERS); - } - - @Test - public void testGenerateSupportedJAGIssuersInvalidFormat() { - // Test with invalid format (too many components) - System.setProperty(ZTSConsts.ZTS_PROP_OPENID_JAG_ISSUERS, - "https://issuer1.example.com|https://issuer1.example.com/jwks|https://proxy.example.com|extra"); - - List resolvers = zts.generateSupportedJAGIssuers(); - - // Should skip invalid entries - assertNotNull(resolvers); - assertEquals(resolvers.size(), 0); - - System.clearProperty(ZTSConsts.ZTS_PROP_OPENID_JAG_ISSUERS); - } - - @Test - public void testGenerateSupportedJAGIssuersEmptyIssuerAndJwks() { - // Test with empty issuer URI and default jwks_uri - System.setProperty(ZTSConsts.ZTS_PROP_OPENID_JAG_ISSUERS, "|"); - - List resolvers = zts.generateSupportedJAGIssuers(); - - // Should skip invalid entries - assertNotNull(resolvers); - assertEquals(resolvers.size(), 0); - - System.clearProperty(ZTSConsts.ZTS_PROP_OPENID_JAG_ISSUERS); - } - - @Test - public void testGenerateSupportedJAGIssuersMixedValidInvalid() { - // Test with mixed valid and invalid issuers - System.setProperty(ZTSConsts.ZTS_PROP_OPENID_JAG_ISSUERS, - "https://issuer1.example.com|https://issuer1.example.com/jwks," + - "|||," + // invalid - "||proxy-url," + // invalid - "https://issuer2.example.com|https://issuer2.example.com/jwks"); - - List resolvers = zts.generateSupportedJAGIssuers(); - - // Should only include valid entries - assertNotNull(resolvers); - assertEquals(resolvers.size(), 2); - assertEquals(resolvers.get(0).getJwksUri(), "https://issuer1.example.com/jwks"); - assertEquals(resolvers.get(1).getJwksUri(), "https://issuer2.example.com/jwks"); - - System.clearProperty(ZTSConsts.ZTS_PROP_OPENID_JAG_ISSUERS); - } - - @Test - public void testGenerateSupportedJAGIssuersIssuerOnly() { - // Test with issuer only (no default jwks_uri) - System.setProperty(ZTSConsts.ZTS_PROP_OPENID_JAG_ISSUERS, "https://issuer1.example.com"); - - List resolvers = zts.generateSupportedJAGIssuers(); - - // This should attempt to fetch from openid-configuration and may fail - // The actual behavior depends on whether JwtsHelper.extractJwksUri can reach the endpoint - // For unit test, we just verify the method doesn't crash - assertNotNull(resolvers); - - System.clearProperty(ZTSConsts.ZTS_PROP_OPENID_JAG_ISSUERS); - } - - @Test - public void testLoadJWTProcessor() { - // Test loadJWTProcessor with no JAG issuers configured - System.clearProperty(ZTSConsts.ZTS_PROP_OPENID_JAG_ISSUERS); - System.setProperty(ZTSConsts.ZTS_PROP_OPENID_ISSUER, "https://athenz.example.com:4443/zts/v1"); - - // Create a new ZTSImpl instance to test loadJWTProcessor - ChangeLogStore structStore = new MockZMSFileChangeLogStore("/tmp/zts_server_unit_tests/zts_root", - privateKey, "0"); - DataStore testStore = new DataStore(structStore, cloudStore, ztsMetric); - ZTSImpl testZts = new ZTSImpl(cloudStore, testStore); - - // Verify that jwtJAGProcessor is not null after initialization - assertNotNull(testZts.tokenConfigOptions.getJwtJAGProcessor()); - } - - @Test - public void testLoadJWTProcessorVerifyNonNull() { - // Test that loadJWTProcessor creates a non-null jwtJAGProcessor - // The existing zts instance from setup should have called loadJWTProcessor - assertNotNull(zts.tokenConfigOptions.getJwtJAGProcessor()); - } - - @Test - public void testLoadJWTProcessorWithGeneratedResolvers() { - // Test that loadJWTProcessor correctly uses resolvers from generateSupportedJAGIssuers - // Set up a configuration with default jwks URIs - System.setProperty(ZTSConsts.ZTS_PROP_OPENID_JAG_ISSUERS, - "https://localhost:8443|https://localhost:8443/oauth2/keys"); - - // Get the resolvers that would be generated - List resolvers = zts.generateSupportedJAGIssuers(); - - // Verify we got one resolver with the expected jwks URI - assertNotNull(resolvers); - assertEquals(resolvers.size(), 1); - assertEquals(resolvers.get(0).getJwksUri(), "https://localhost:8443/oauth2/keys"); - assertNull(resolvers.get(0).getProxyUrl()); - - System.clearProperty(ZTSConsts.ZTS_PROP_OPENID_JAG_ISSUERS); - } - - @Test - public void testLoadJWTProcessorAddsZtsIssuer() { - // Test that loadJWTProcessor adds the ZTS server's own issuer - // Even with no JAG issuers, the list should include the ZTS server - System.clearProperty(ZTSConsts.ZTS_PROP_OPENID_JAG_ISSUERS); - - // The generateSupportedJAGIssuers should return empty list - List jagResolvers = zts.generateSupportedJAGIssuers(); - assertEquals(jagResolvers.size(), 0); - - // But loadJWTProcessor should add the ZTS server as the last entry - // which is verified by the non-null jwtJAGProcessor - assertNotNull(zts.tokenConfigOptions.getJwtJAGProcessor()); - } } diff --git a/servers/zts/src/test/java/com/yahoo/athenz/zts/cache/DataCacheTest.java b/servers/zts/src/test/java/com/yahoo/athenz/zts/cache/DataCacheTest.java index 93c80f4fae9..b80f3eb29a0 100644 --- a/servers/zts/src/test/java/com/yahoo/athenz/zts/cache/DataCacheTest.java +++ b/servers/zts/src/test/java/com/yahoo/athenz/zts/cache/DataCacheTest.java @@ -1336,5 +1336,51 @@ public void testIsWorkloadStoreExcludedProvider() { assertFalse(cache.isWorkloadStoreExcludedProvider("sys.openstack.classic")); assertTrue(cache.isWorkloadStoreExcludedProvider("omega.k8s.identity")); } + + @Test + public void testDataCacheClientIds() { + + final String domainName = "client-id-test-domain"; + + DataCache cache = new DataCache(); + DomainData domainData = new DomainData(); + domainData.setName(domainName); + cache.setDomainData(domainData); + + ServiceIdentity serviceIdentity1 = new ServiceIdentity(); + serviceIdentity1.setName(domainName + ".service1"); + serviceIdentity1.setClientId("client1"); + + ServiceIdentity serviceIdentity2 = new ServiceIdentity(); + serviceIdentity2.setName(domainName + ".service2"); + serviceIdentity2.setClientId("client2"); + + ServiceIdentity serviceIdentity3 = new ServiceIdentity(); + serviceIdentity3.setName(domainName + ".service3"); + + cache.processServiceIdentity(serviceIdentity1); + cache.processServiceIdentity(serviceIdentity2); + cache.processServiceIdentity(serviceIdentity3); + + assertEquals("client1", cache.getServiceIdentityClientId(domainName + ".service1")); + assertEquals("client2", cache.getServiceIdentityClientId(domainName + ".service2")); + assertNull(cache.getServiceIdentityClientId(domainName + ".service3")); + + // now update service3 with a client id + serviceIdentity3.setClientId("client3"); + cache.processServiceIdentity(serviceIdentity3); + + // remove client id from service2 + serviceIdentity2.setClientId(null); + cache.processServiceIdentity(serviceIdentity2); + + // change client id for service1 + serviceIdentity1.setClientId("newclient1"); + cache.processServiceIdentity(serviceIdentity1); + + assertEquals("newclient1", cache.getServiceIdentityClientId(domainName + ".service1")); + assertNull(cache.getServiceIdentityClientId(domainName + ".service2")); + assertEquals("client3", cache.getServiceIdentityClientId(domainName + ".service3")); + } } diff --git a/servers/zts/src/test/java/com/yahoo/athenz/zts/store/DataStoreTest.java b/servers/zts/src/test/java/com/yahoo/athenz/zts/store/DataStoreTest.java index 83818249be1..c01166b649c 100644 --- a/servers/zts/src/test/java/com/yahoo/athenz/zts/store/DataStoreTest.java +++ b/servers/zts/src/test/java/com/yahoo/athenz/zts/store/DataStoreTest.java @@ -5574,4 +5574,186 @@ public void testGetServiceSecret() { assertNull(store.getServiceSecret("coretech", null)); assertNull(store.getServiceSecret(null, "storage")); } + + @Test + public void testGetServiceClientId() { + + ChangeLogStore clogStore = new MockZMSFileChangeLogStore("/tmp/zts_server_unit_tests/zts_root", pkey, "0"); + DataStore store = new DataStore(clogStore, null, ztsMetric); + + // Create a domain with services that have client IDs + DataCache dataCache = new DataCache(); + DomainData domainData = new DomainData(); + domainData.setName("coretech"); + + ServiceIdentity service1 = new ServiceIdentity(); + service1.setName("coretech.storage"); + service1.setClientId("client-id-1"); + + ServiceIdentity service2 = new ServiceIdentity(); + service2.setName("coretech.api"); + service2.setClientId("client-id-2"); + + ServiceIdentity service3 = new ServiceIdentity(); + service3.setName("coretech.backend"); + // service3 has no client ID + + List services = new ArrayList<>(); + services.add(service1); + services.add(service2); + services.add(service3); + + dataCache.processServiceIdentity(service1); + dataCache.processServiceIdentity(service2); + dataCache.processServiceIdentity(service3); + + domainData.setServices(services); + dataCache.setDomainData(domainData); + + store.addDomainToCache("coretech", dataCache); + + // Test: service with client ID + assertEquals(store.getServiceClientId("coretech", "coretech.storage"), "client-id-1"); + assertEquals(store.getServiceClientId("coretech", "coretech.api"), "client-id-2"); + + // Test: service without client ID + assertNull(store.getServiceClientId("coretech", "coretech.backend")); + + // Test: non-existent service + assertNull(store.getServiceClientId("coretech", "coretech.unknown")); + + // Test: non-existent domain + assertNull(store.getServiceClientId("unknown", "unknown.service")); + } + + @Test + public void testGetServiceClientIdMultipleDomains() { + + ChangeLogStore clogStore = new MockZMSFileChangeLogStore("/tmp/zts_server_unit_tests/zts_root", pkey, "0"); + DataStore store = new DataStore(clogStore, null, ztsMetric); + + // Create first domain + DataCache dataCache1 = new DataCache(); + DomainData domainData1 = new DomainData(); + domainData1.setName("coretech"); + + ServiceIdentity service1 = new ServiceIdentity(); + service1.setName("coretech.storage"); + service1.setClientId("coretech-client-id"); + + dataCache1.processServiceIdentity(service1); + domainData1.setServices(Arrays.asList(service1)); + dataCache1.setDomainData(domainData1); + store.addDomainToCache("coretech", dataCache1); + + // Create second domain + DataCache dataCache2 = new DataCache(); + DomainData domainData2 = new DomainData(); + domainData2.setName("sports"); + + ServiceIdentity service2 = new ServiceIdentity(); + service2.setName("sports.api"); + service2.setClientId("sports-client-id"); + + dataCache2.processServiceIdentity(service2); + domainData2.setServices(Arrays.asList(service2)); + dataCache2.setDomainData(domainData2); + store.addDomainToCache("sports", dataCache2); + + // Test: verify each domain returns correct client ID + assertEquals(store.getServiceClientId("coretech", "coretech.storage"), "coretech-client-id"); + assertEquals(store.getServiceClientId("sports", "sports.api"), "sports-client-id"); + + // Test: verify cross-domain lookup returns null + assertNull(store.getServiceClientId("coretech", "sports.api")); + assertNull(store.getServiceClientId("sports", "coretech.storage")); + } + + @Test + public void testGetServiceClientIdEmptyString() { + + ChangeLogStore clogStore = new MockZMSFileChangeLogStore("/tmp/zts_server_unit_tests/zts_root", pkey, "0"); + DataStore store = new DataStore(clogStore, null, ztsMetric); + + DataCache dataCache = new DataCache(); + DomainData domainData = new DomainData(); + domainData.setName("coretech"); + + ServiceIdentity service = new ServiceIdentity(); + service.setName("coretech.storage"); + service.setClientId("client-id"); + + dataCache.processServiceIdentity(service); + domainData.setServices(Arrays.asList(service)); + dataCache.setDomainData(domainData); + store.addDomainToCache("coretech", dataCache); + + // Test: empty string domain name + assertNull(store.getServiceClientId("", "coretech.storage")); + + // Test: empty string service name + assertNull(store.getServiceClientId("coretech", "")); + } + + @Test + public void testGetServiceClientIdClientIdRemoved() { + + ChangeLogStore clogStore = new MockZMSFileChangeLogStore("/tmp/zts_server_unit_tests/zts_root", pkey, "0"); + DataStore store = new DataStore(clogStore, null, ztsMetric); + + DataCache dataCache = new DataCache(); + DomainData domainData = new DomainData(); + domainData.setName("coretech"); + + ServiceIdentity service = new ServiceIdentity(); + service.setName("coretech.storage"); + service.setClientId("client-id-1"); + + dataCache.processServiceIdentity(service); + domainData.setServices(Arrays.asList(service)); + dataCache.setDomainData(domainData); + store.addDomainToCache("coretech", dataCache); + + // Verify initial client ID + assertEquals(store.getServiceClientId("coretech", "coretech.storage"), "client-id-1"); + + // Update service to remove client ID + service.setClientId(null); + dataCache.processServiceIdentity(service); + store.addDomainToCache("coretech", dataCache); + + // Verify client ID is now null + assertNull(store.getServiceClientId("coretech", "coretech.storage")); + } + + @Test + public void testGetServiceClientIdClientIdUpdated() { + + ChangeLogStore clogStore = new MockZMSFileChangeLogStore("/tmp/zts_server_unit_tests/zts_root", pkey, "0"); + DataStore store = new DataStore(clogStore, null, ztsMetric); + + DataCache dataCache = new DataCache(); + DomainData domainData = new DomainData(); + domainData.setName("coretech"); + + ServiceIdentity service = new ServiceIdentity(); + service.setName("coretech.storage"); + service.setClientId("client-id-1"); + + dataCache.processServiceIdentity(service); + domainData.setServices(Arrays.asList(service)); + dataCache.setDomainData(domainData); + store.addDomainToCache("coretech", dataCache); + + // Verify initial client ID + assertEquals(store.getServiceClientId("coretech", "coretech.storage"), "client-id-1"); + + // Update service with new client ID + service.setClientId("client-id-2"); + dataCache.processServiceIdentity(service); + store.addDomainToCache("coretech", dataCache); + + // Verify client ID is updated + assertEquals(store.getServiceClientId("coretech", "coretech.storage"), "client-id-2"); + } } diff --git a/servers/zts/src/test/java/com/yahoo/athenz/zts/token/ProviderConfigManagerTest.java b/servers/zts/src/test/java/com/yahoo/athenz/zts/token/ProviderConfigManagerTest.java new file mode 100644 index 00000000000..65396eb47e0 --- /dev/null +++ b/servers/zts/src/test/java/com/yahoo/athenz/zts/token/ProviderConfigManagerTest.java @@ -0,0 +1,453 @@ +/* + * Copyright The Athenz Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.yahoo.athenz.zts.token; + +import com.yahoo.athenz.auth.TokenExchangeIdentityProvider; +import com.yahoo.athenz.auth.token.OAuth2Token; +import com.yahoo.athenz.auth.token.jwts.JwtsResolver; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.nio.file.Files; +import java.util.List; + +import static org.testng.Assert.*; + +public class ProviderConfigManagerTest { + + private File tempDir; + private File tempConfigFile; + + @BeforeMethod + public void setUp() throws IOException { + tempDir = Files.createTempDirectory("provider-config-test").toFile(); + tempConfigFile = new File(tempDir, "provider-config.json"); + } + + @AfterMethod + public void tearDown() { + if (tempConfigFile != null && tempConfigFile.exists()) { + tempConfigFile.delete(); + } + if (tempDir != null && tempDir.exists()) { + tempDir.delete(); + } + } + + @Test + public void testConstructorWithNullFilePath() { + ProviderConfigManager manager = new ProviderConfigManager(null); + assertNotNull(manager); + assertNotNull(manager.getJwtsResolvers()); + assertEquals(manager.getJwtsResolvers().size(), 0); + assertNull(manager.getProvider("https://example.com")); + } + + @Test + public void testConstructorWithEmptyFilePath() { + ProviderConfigManager manager = new ProviderConfigManager(""); + assertNotNull(manager); + assertNotNull(manager.getJwtsResolvers()); + assertEquals(manager.getJwtsResolvers().size(), 0); + assertNull(manager.getProvider("https://example.com")); + } + + @Test(expectedExceptions = IllegalArgumentException.class) + public void testConstructorWithNonExistentFile() { + new ProviderConfigManager("/non/existent/path/config.json"); + } + + @Test + public void testConstructorWithEmptyJsonArray() throws IOException { + writeJsonToFile("[]"); + + ProviderConfigManager manager = new ProviderConfigManager(tempConfigFile.getAbsolutePath()); + assertNotNull(manager); + assertNotNull(manager.getJwtsResolvers()); + assertEquals(manager.getJwtsResolvers().size(), 0); + } + + @Test + public void testConstructorWithValidConfigWithJwksUri() throws IOException { + String json = "[{" + + "\"issuerUri\": \"https://example.com/oauth2\"," + + "\"jwksUri\": \"https://example.com/.well-known/jwks.json\"" + + "}]"; + writeJsonToFile(json); + + ProviderConfigManager manager = new ProviderConfigManager(tempConfigFile.getAbsolutePath()); + assertNotNull(manager); + assertNotNull(manager.getJwtsResolvers()); + assertEquals(manager.getJwtsResolvers().size(), 1); + assertNull(manager.getProvider("https://example.com/oauth2")); + } + + @Test + public void testConstructorWithValidConfigWithProviderClass() throws IOException { + String json = "[{" + + "\"issuerUri\": \"https://example.com/oauth2\"," + + "\"jwksUri\": \"https://example.com/.well-known/jwks.json\"," + + "\"providerClassName\": \"" + MockTokenExchangeIdentityProvider.class.getName() + "\"" + + "}]"; + writeJsonToFile(json); + + ProviderConfigManager manager = new ProviderConfigManager(tempConfigFile.getAbsolutePath()); + assertNotNull(manager); + assertNotNull(manager.getJwtsResolvers()); + assertEquals(manager.getJwtsResolvers().size(), 1); + + TokenExchangeIdentityProvider provider = manager.getProvider("https://example.com/oauth2"); + assertNotNull(provider); + assertTrue(provider instanceof MockTokenExchangeIdentityProvider); + } + + @Test + public void testConstructorWithValidConfigWithProxyUrl() throws IOException { + String json = "[{" + + "\"issuerUri\": \"https://example.com/oauth2\"," + + "\"jwksUri\": \"https://example.com/.well-known/jwks.json\"," + + "\"proxyUrl\": \"https://proxy.example.com:8080\"" + + "}]"; + writeJsonToFile(json); + + ProviderConfigManager manager = new ProviderConfigManager(tempConfigFile.getAbsolutePath()); + assertNotNull(manager); + assertNotNull(manager.getJwtsResolvers()); + assertEquals(manager.getJwtsResolvers().size(), 1); + } + + @Test + public void testConstructorWithMultipleProviders() throws IOException { + String json = "[{" + + "\"issuerUri\": \"https://example1.com/oauth2\"," + + "\"jwksUri\": \"https://example1.com/.well-known/jwks.json\"" + + "}," + + "{" + + "\"issuerUri\": \"https://example2.com/oauth2\"," + + "\"jwksUri\": \"https://example2.com/.well-known/jwks.json\"" + + "}]"; + writeJsonToFile(json); + + ProviderConfigManager manager = new ProviderConfigManager(tempConfigFile.getAbsolutePath()); + assertNotNull(manager); + assertNotNull(manager.getJwtsResolvers()); + assertEquals(manager.getJwtsResolvers().size(), 2); + } + + @Test + public void testConstructorWithInvalidProviderClass() throws IOException { + String json = "[{" + + "\"issuerUri\": \"https://example.com/oauth2\"," + + "\"jwksUri\": \"https://example.com/.well-known/jwks.json\"," + + "\"providerClassName\": \"com.nonexistent.Class\"" + + "}]"; + writeJsonToFile(json); + + ProviderConfigManager manager = new ProviderConfigManager(tempConfigFile.getAbsolutePath()); + assertNotNull(manager); + // Provider should not be loaded due to invalid class + assertNull(manager.getProvider("https://example.com/oauth2")); + // no resolver should be created + assertEquals(manager.getJwtsResolvers().size(), 0); + } + + @Test + public void testConstructorWithMissingIssuerUri() throws IOException { + String json = "[{" + + "\"jwksUri\": \"https://example.com/.well-known/jwks.json\"" + + "}]"; + writeJsonToFile(json); + + ProviderConfigManager manager = new ProviderConfigManager(tempConfigFile.getAbsolutePath()); + assertNotNull(manager); + // Config without issuerUri should be skipped + assertEquals(manager.getJwtsResolvers().size(), 0); + } + + @Test + public void testConstructorWithEmptyIssuerUri() throws IOException { + String json = "[{" + + "\"issuerUri\": \"\"," + + "\"jwksUri\": \"https://example.com/.well-known/jwks.json\"" + + "}]"; + writeJsonToFile(json); + + ProviderConfigManager manager = new ProviderConfigManager(tempConfigFile.getAbsolutePath()); + assertNotNull(manager); + // Config with empty issuerUri should be skipped + assertEquals(manager.getJwtsResolvers().size(), 0); + } + + @Test + public void testConstructorWithMissingJwksUri() throws IOException { + String json = "[{" + + "\"issuerUri\": \"https://example.com/oauth2\"" + + "}]"; + writeJsonToFile(json); + + ProviderConfigManager manager = new ProviderConfigManager(tempConfigFile.getAbsolutePath()); + assertNotNull(manager); + // Config without jwksUri and unable to extract from openid-config should be skipped + // Note: extractJwksUri will likely return null in test environment + assertEquals(manager.getJwtsResolvers().size(), 0); + } + + @Test + public void testProcessProviderConfigWithValidConfig() { + ProviderConfigManager manager = new ProviderConfigManager(null); + + ProviderConfig config = new ProviderConfig(); + config.setIssuerUri("https://test.example.com/oauth2"); + config.setJwksUri("https://test.example.com/.well-known/jwks.json"); + config.setProviderClassName(MockTokenExchangeIdentityProvider.class.getName()); + + manager.processProviderConfig(config); + + assertEquals(manager.getJwtsResolvers().size(), 1); + assertNotNull(manager.getProvider("https://test.example.com/oauth2")); + } + + @Test + public void testProcessProviderConfigWithNullIssuerUri() { + ProviderConfigManager manager = new ProviderConfigManager(null); + + ProviderConfig config = new ProviderConfig(); + config.setIssuerUri(null); + config.setJwksUri("https://test.example.com/.well-known/jwks.json"); + + manager.processProviderConfig(config); + + // Should not add any resolver due to missing issuerUri + assertEquals(manager.getJwtsResolvers().size(), 0); + } + + @Test + public void testProcessProviderConfigWithEmptyIssuerUri() { + ProviderConfigManager manager = new ProviderConfigManager(null); + + ProviderConfig config = new ProviderConfig(); + config.setIssuerUri(""); + config.setJwksUri("https://test.example.com/.well-known/jwks.json"); + + manager.processProviderConfig(config); + + // Should not add any resolver due to empty issuerUri + assertEquals(manager.getJwtsResolvers().size(), 0); + } + + @Test + public void testProcessProviderConfigWithMissingJwksUri() { + ProviderConfigManager manager = new ProviderConfigManager(null); + + ProviderConfig config = new ProviderConfig(); + config.setIssuerUri("https://test.example.com/oauth2"); + // No jwksUri set, and extractJwksUri will likely return null in test + + manager.processProviderConfig(config); + + // Should not add any resolver due to missing jwksUri + assertEquals(manager.getJwtsResolvers().size(), 0); + } + + @Test + public void testProcessProviderConfigWithInvalidProviderClass() { + ProviderConfigManager manager = new ProviderConfigManager(null); + + ProviderConfig config = new ProviderConfig(); + config.setIssuerUri("https://test.example.com/oauth2"); + config.setJwksUri("https://test.example.com/.well-known/jwks.json"); + config.setProviderClassName("com.nonexistent.InvalidClass"); + + manager.processProviderConfig(config); + + assertEquals(manager.getJwtsResolvers().size(), 0); + assertNull(manager.getProvider("https://test.example.com/oauth2")); + } + + + @Test + public void testGetJwtsResolvers() throws IOException { + String json = "[{" + + "\"issuerUri\": \"https://example1.com/oauth2\"," + + "\"jwksUri\": \"https://example1.com/.well-known/jwks.json\"" + + "}," + + "{" + + "\"issuerUri\": \"https://example2.com/oauth2\"," + + "\"jwksUri\": \"https://example2.com/.well-known/jwks.json\"" + + "}]"; + writeJsonToFile(json); + + ProviderConfigManager manager = new ProviderConfigManager(tempConfigFile.getAbsolutePath()); + + List resolvers = manager.getJwtsResolvers(); + assertNotNull(resolvers); + assertEquals(resolvers.size(), 2); + + // Verify resolvers are not null + assertNotNull(resolvers.get(0)); + assertNotNull(resolvers.get(1)); + + // Verify the list is not modifiable (if it's unmodifiable) + // Actually, looking at the code, it returns the list directly, so it might be modifiable + // But we'll test that getJwtsResolvers returns the same list instance + List resolvers2 = manager.getJwtsResolvers(); + assertEquals(resolvers, resolvers2); + } + + @Test + public void testGetProvider() throws IOException { + String json = "[{" + + "\"issuerUri\": \"https://example1.com/oauth2\"," + + "\"jwksUri\": \"https://example1.com/.well-known/jwks.json\"," + + "\"providerClassName\": \"" + MockTokenExchangeIdentityProvider.class.getName() + "\"" + + "}," + + "{" + + "\"issuerUri\": \"https://example2.com/oauth2\"," + + "\"jwksUri\": \"https://example2.com/.well-known/jwks.json\"" + + "}]"; + writeJsonToFile(json); + + ProviderConfigManager manager = new ProviderConfigManager(tempConfigFile.getAbsolutePath()); + + // Test getting provider that exists + TokenExchangeIdentityProvider provider1 = manager.getProvider("https://example1.com/oauth2"); + assertNotNull(provider1); + assertTrue(provider1 instanceof MockTokenExchangeIdentityProvider); + + // Test getting provider that doesn't exist + assertNull(manager.getProvider("https://example2.com/oauth2")); + + // Test getting provider with non-existent issuer + assertNull(manager.getProvider("https://nonexistent.com/oauth2")); + + // Test getting provider with null issuer + assertNull(manager.getProvider(null)); + } + + @Test + public void testMultipleProvidersWithSameIssuerUri() throws IOException { + // When multiple providers have the same issuerUri, the last one should win + String json = "[{" + + "\"issuerUri\": \"https://example.com/oauth2\"," + + "\"jwksUri\": \"https://example.com/.well-known/jwks.json\"," + + "\"providerClassName\": \"" + MockTokenExchangeIdentityProvider.class.getName() + "\"" + + "}," + + "{" + + "\"issuerUri\": \"https://example.com/oauth2\"," + + "\"jwksUri\": \"https://example.com/.well-known/jwks.json\"" + + "}]"; + writeJsonToFile(json); + + ProviderConfigManager manager = new ProviderConfigManager(tempConfigFile.getAbsolutePath()); + + // Should have 2 resolvers (one for each config entry) + assertEquals(manager.getJwtsResolvers().size(), 2); + + // Provider should exist (from first entry) + assertNotNull(manager.getProvider("https://example.com/oauth2")); + } + + @Test + public void testConstructorWithMalformedJson() throws IOException { + writeJsonToFile("not valid json"); + + try { + new ProviderConfigManager(tempConfigFile.getAbsolutePath()); + // Depending on JSON parsing, this might throw an exception or handle it gracefully + // We'll test that it doesn't crash the test + } catch (Exception e) { + // Expected - malformed JSON should cause an error + assertTrue(e instanceof IllegalArgumentException || e.getCause() instanceof Exception); + } + } + + @Test + public void testConstructorWithNullJsonArray() throws IOException { + // Test with JSON that parses to null + writeJsonToFile("null"); + + ProviderConfigManager manager = new ProviderConfigManager(tempConfigFile.getAbsolutePath()); + assertNotNull(manager); + assertEquals(manager.getJwtsResolvers().size(), 0); + } + + @Test + public void testProcessProviderConfigWithProxyUrl() { + ProviderConfigManager manager = new ProviderConfigManager(null); + + ProviderConfig config = new ProviderConfig(); + config.setIssuerUri("https://test.example.com/oauth2"); + config.setJwksUri("https://test.example.com/.well-known/jwks.json"); + config.setProxyUrl("https://proxy.example.com:8080"); + + manager.processProviderConfig(config); + + assertEquals(manager.getJwtsResolvers().size(), 1); + // Verify resolver was created (we can't easily verify proxyUrl was passed without reflection) + } + + @Test + public void testProcessProviderConfigProviderClassNotTokenExchangeIdentityProvider() throws IOException { + // Test with a class that exists but doesn't implement TokenExchangeIdentityProvider + String json = "[{" + + "\"issuerUri\": \"https://example.com/oauth2\"," + + "\"jwksUri\": \"https://example.com/.well-known/jwks.json\"," + + "\"providerClassName\": \"" + String.class.getName() + "\"" + + "}]"; + writeJsonToFile(json); + + try { + ProviderConfigManager manager = new ProviderConfigManager(tempConfigFile.getAbsolutePath()); + // Should handle ClassCastException gracefully + assertNull(manager.getProvider("https://example.com/oauth2")); + } catch (Exception e) { + // ClassCastException should be caught and logged, but might propagate + assertTrue(e instanceof IllegalArgumentException || + e.getCause() instanceof ClassCastException || + e.getCause() instanceof Exception); + } + } + + // Helper method to write JSON to temp file + private void writeJsonToFile(String json) throws IOException { + try (FileWriter writer = new FileWriter(tempConfigFile)) { + writer.write(json); + } + } + + // Mock implementation of TokenExchangeIdentityProvider for testing + public static class MockTokenExchangeIdentityProvider implements TokenExchangeIdentityProvider { + @Override + public String getTokenIdentity(OAuth2Token token) { + return null; + } + + @Override + public String getTokenAudience(OAuth2Token token) { + return ""; + } + + @Override + public List getTokenExchangeClaims() { + return null; + } + } +} + diff --git a/servers/zts/src/test/java/com/yahoo/athenz/zts/token/ProviderConfigTest.java b/servers/zts/src/test/java/com/yahoo/athenz/zts/token/ProviderConfigTest.java new file mode 100644 index 00000000000..e0f06c895d8 --- /dev/null +++ b/servers/zts/src/test/java/com/yahoo/athenz/zts/token/ProviderConfigTest.java @@ -0,0 +1,262 @@ +/* + * Copyright The Athenz Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.yahoo.athenz.zts.token; + +import org.testng.annotations.Test; + +import static org.testng.Assert.*; + +public class ProviderConfigTest { + + @Test + public void testDefaultConstructor() { + ProviderConfig config = new ProviderConfig(); + assertNotNull(config); + assertNull(config.getIssuerUri()); + assertNull(config.getProxyUrl()); + assertNull(config.getJwksUri()); + assertNull(config.getProviderClassName()); + } + + @Test + public void testIssuerUriGetterAndSetter() { + ProviderConfig config = new ProviderConfig(); + + // Initially null + assertNull(config.getIssuerUri()); + + // Set a value + String issuerUri = "https://athenz.io/oauth2/v1"; + config.setIssuerUri(issuerUri); + assertNotNull(config.getIssuerUri()); + assertEquals(config.getIssuerUri(), issuerUri); + + // Set to null + config.setIssuerUri(null); + assertNull(config.getIssuerUri()); + + // Set a different value + String anotherIssuerUri = "https://example.com/oauth2"; + config.setIssuerUri(anotherIssuerUri); + assertEquals(config.getIssuerUri(), anotherIssuerUri); + + // Set empty string + config.setIssuerUri(""); + assertEquals(config.getIssuerUri(), ""); + } + + @Test + public void testProxyUrlGetterAndSetter() { + ProviderConfig config = new ProviderConfig(); + + // Initially null + assertNull(config.getProxyUrl()); + + // Set a value + String proxyUrl = "https://proxy.example.com"; + config.setProxyUrl(proxyUrl); + assertNotNull(config.getProxyUrl()); + assertEquals(config.getProxyUrl(), proxyUrl); + + // Set to null + config.setProxyUrl(null); + assertNull(config.getProxyUrl()); + + // Set a different value + String anotherProxyUrl = "https://another-proxy.example.com"; + config.setProxyUrl(anotherProxyUrl); + assertEquals(config.getProxyUrl(), anotherProxyUrl); + + // Set empty string + config.setProxyUrl(""); + assertEquals(config.getProxyUrl(), ""); + } + + @Test + public void testJwksUriGetterAndSetter() { + ProviderConfig config = new ProviderConfig(); + + // Initially null + assertNull(config.getJwksUri()); + + // Set a value + String jwksUri = "https://athenz.io/.well-known/jwks.json"; + config.setJwksUri(jwksUri); + assertNotNull(config.getJwksUri()); + assertEquals(config.getJwksUri(), jwksUri); + + // Set to null + config.setJwksUri(null); + assertNull(config.getJwksUri()); + + // Set a different value + String anotherJwksUri = "https://example.com/.well-known/jwks.json"; + config.setJwksUri(anotherJwksUri); + assertEquals(config.getJwksUri(), anotherJwksUri); + + // Set empty string + config.setJwksUri(""); + assertEquals(config.getJwksUri(), ""); + } + + @Test + public void testProviderClassNameGetterAndSetter() { + ProviderConfig config = new ProviderConfig(); + + // Initially null + assertNull(config.getProviderClassName()); + + // Set a value + String providerClassName = "com.yahoo.athenz.auth.SimpleServiceIdentityProvider"; + config.setProviderClassName(providerClassName); + assertNotNull(config.getProviderClassName()); + assertEquals(config.getProviderClassName(), providerClassName); + + // Set to null + config.setProviderClassName(null); + assertNull(config.getProviderClassName()); + + // Set a different value + String anotherProviderClassName = "com.example.CustomProvider"; + config.setProviderClassName(anotherProviderClassName); + assertEquals(config.getProviderClassName(), anotherProviderClassName); + + // Set empty string + config.setProviderClassName(""); + assertEquals(config.getProviderClassName(), ""); + } + + @Test + public void testAllFieldsSetAndGet() { + ProviderConfig config = new ProviderConfig(); + + String issuerUri = "https://athenz.io/oauth2/v1"; + String proxyUrl = "https://proxy.example.com"; + String jwksUri = "https://athenz.io/.well-known/jwks.json"; + String providerClassName = "com.yahoo.athenz.auth.SimpleServiceIdentityProvider"; + + // Set all fields + config.setIssuerUri(issuerUri); + config.setProxyUrl(proxyUrl); + config.setJwksUri(jwksUri); + config.setProviderClassName(providerClassName); + + // Verify all fields are set correctly + assertEquals(config.getIssuerUri(), issuerUri); + assertEquals(config.getProxyUrl(), proxyUrl); + assertEquals(config.getJwksUri(), jwksUri); + assertEquals(config.getProviderClassName(), providerClassName); + } + + @Test + public void testIndependentFields() { + ProviderConfig config = new ProviderConfig(); + + // Set issuerUri + config.setIssuerUri("https://athenz.io/oauth2/v1"); + assertNotNull(config.getIssuerUri()); + assertNull(config.getProxyUrl()); + assertNull(config.getJwksUri()); + assertNull(config.getProviderClassName()); + + // Set proxyUrl independently + config.setProxyUrl("https://proxy.example.com"); + assertNotNull(config.getIssuerUri()); + assertNotNull(config.getProxyUrl()); + assertNull(config.getJwksUri()); + assertNull(config.getProviderClassName()); + + // Set jwksUri independently + config.setJwksUri("https://athenz.io/.well-known/jwks.json"); + assertNotNull(config.getIssuerUri()); + assertNotNull(config.getProxyUrl()); + assertNotNull(config.getJwksUri()); + assertNull(config.getProviderClassName()); + + // Set providerClassName independently + config.setProviderClassName("com.yahoo.athenz.auth.SimpleServiceIdentityProvider"); + assertNotNull(config.getIssuerUri()); + assertNotNull(config.getProxyUrl()); + assertNotNull(config.getJwksUri()); + assertNotNull(config.getProviderClassName()); + } + + @Test + public void testToString() { + ProviderConfig config = new ProviderConfig(); + + // Test toString with all null fields + String toString = config.toString(); + assertNotNull(toString); + assertTrue(toString.contains("ProviderConfig{")); + assertTrue(toString.contains("issuerUri='null'")); + assertTrue(toString.contains("proxyUrl='null'")); + assertTrue(toString.contains("jwksUri='null'")); + assertTrue(toString.contains("providerClassName='null'")); + + // Test toString with all fields set + config.setIssuerUri("https://athenz.io/oauth2/v1"); + config.setProxyUrl("https://proxy.example.com"); + config.setJwksUri("https://athenz.io/.well-known/jwks.json"); + config.setProviderClassName("com.yahoo.athenz.auth.SimpleServiceIdentityProvider"); + + toString = config.toString(); + assertNotNull(toString); + assertTrue(toString.contains("ProviderConfig{")); + assertTrue(toString.contains("issuerUri='https://athenz.io/oauth2/v1'")); + assertTrue(toString.contains("proxyUrl='https://proxy.example.com'")); + assertTrue(toString.contains("jwksUri='https://athenz.io/.well-known/jwks.json'")); + assertTrue(toString.contains("providerClassName='com.yahoo.athenz.auth.SimpleServiceIdentityProvider'")); + } + + @Test + public void testToStringWithEmptyStrings() { + ProviderConfig config = new ProviderConfig(); + config.setIssuerUri(""); + config.setProxyUrl(""); + config.setJwksUri(""); + config.setProviderClassName(""); + + String toString = config.toString(); + assertNotNull(toString); + assertTrue(toString.contains("issuerUri=''")); + assertTrue(toString.contains("proxyUrl=''")); + assertTrue(toString.contains("jwksUri=''")); + assertTrue(toString.contains("providerClassName=''")); + } + + @Test + public void testMultipleSetOperations() { + ProviderConfig config = new ProviderConfig(); + + // Set values multiple times + config.setIssuerUri("https://first.example.com"); + config.setIssuerUri("https://second.example.com"); + assertEquals(config.getIssuerUri(), "https://second.example.com"); + + config.setProxyUrl("https://proxy1.example.com"); + config.setProxyUrl("https://proxy2.example.com"); + assertEquals(config.getProxyUrl(), "https://proxy2.example.com"); + + config.setJwksUri("https://jwks1.example.com"); + config.setJwksUri("https://jwks2.example.com"); + assertEquals(config.getJwksUri(), "https://jwks2.example.com"); + + config.setProviderClassName("com.example.Provider1"); + config.setProviderClassName("com.example.Provider2"); + assertEquals(config.getProviderClassName(), "com.example.Provider2"); + } +} diff --git a/servers/zts/src/test/resources/provider.config.json b/servers/zts/src/test/resources/provider.config.json new file mode 100644 index 00000000000..8361d5326e4 --- /dev/null +++ b/servers/zts/src/test/resources/provider.config.json @@ -0,0 +1,10 @@ +[ + { + "issuerUri": "https://athenz.io:4443/zts/v1", + "jwksUri": "file://src/test/resources/jwt_jwks.json" + }, + { + "issuerUri": "coretech.jwt", + "jwksUri": "file://src/test/resources/jwt_jwks.json" + } +]