diff --git a/docs/zts_access_token_guide.md b/docs/zts_access_token_guide.md index ce23eb6dd4d..14c08184529 100644 --- a/docs/zts_access_token_guide.md +++ b/docs/zts_access_token_guide.md @@ -1,6 +1,6 @@ Access Tokens are used to authorize access to service provider resources. The Access Token contains the set of roles (identified in the token as -scopes) a client belongs to for a specified domain. So, when a client wants +scopes) a client belongs to for one or more specified domains. So, when a client wants to access a resource, this client must obtain the appropriate Access Token from ZTS and use the token in the header of the subsequent HTTP client request. If enabled, the service will @@ -22,13 +22,16 @@ endpoint. The request body must contain the following parameters: grant_type : Value MUST be set to "client_credentials" scope : list of scopes/roles requested in the access token. The caller can either specify to include all roles the principal has access - to in a specific domain (e.g. :domain) or ask for + to in specific domains (e.g. :domain) or ask for specific roles only (e.g. :role.). Scopes are separated by spaces. To request an ID token, the scope must include 'openid' and audience service name (e.g. :service.). The domain - name in id token request match the domain name in the access token - scope. + name in id token request must match the domain name in the access token + scope. ID token requests do not support multiple scope domains. +audience : optional audience domain for the access token. This value is + required when the scope includes more than one domain. If specified, + it must be one of the domains in the requested scope. expires_in : requested expiry time for access token in seconds ``` @@ -44,16 +47,16 @@ Content-Type: application/x-www-form-urlencoded grant_type=client_credentials&scope=demo%3Adomain ``` -If the principal requests an access token only for accessing -`demo` domain and wants to include `readers` and `writers` roles it has access -to in that domain, the request would be: +If the principal requests an access token for accessing `demo` and `sherpa` +domains and wants to include `readers` and `writers` roles it has access to, +the request would be: ``` POST /zts/v1/oauth2/token HTTP/1.1 Host: Content-Type: application/x-www-form-urlencoded -grant_type=client_credentials&scope=demo%3Arole.readers+sherpa%3Arole.writers +grant_type=client_credentials&audience=demo&scope=demo%3Arole.readers+sherpa%3Arole.writers ``` If the principal requests an access token along with an id token for accessing @@ -114,7 +117,10 @@ uid : unique identifier for the principal (same as client Id) sub : subject of the access token (same as client Id) iat : token issue time in seconds (Unix time) exp : token expiry time in seconds (Unix time) -scp : array of scopes are granted to this access token. This is the list of roles that principal can assume in the audience domain +scp : array of scopes granted to this access token. Roles in the audience domain + are listed by role name, while roles from other domains are listed as + :role.. A simple role name always belongs to the + audience domain and cannot authorize a role with the same name in another domain. client_id : client ID (Athenz Principal) of the client that requested the access token ``` diff --git a/servers/zts/conf/zts.properties b/servers/zts/conf/zts.properties index e612248f778..60273a6d093 100644 --- a/servers/zts/conf/zts.properties +++ b/servers/zts/conf/zts.properties @@ -760,6 +760,10 @@ athenz.zts.cert_signer_factory_class=com.yahoo.athenz.zts.cert.impl.SelfCertSign # respective roles or groups the principal is a member of. #athenz.zts.id_token_max_domains=10 +# When requesting access tokens, this value specifies the maximum number of +# domains the scope can include. Default value is 1. +#athenz.zts.access_token_max_domains=1 + # When requesting TLS certificates or role certificates on Google Cloud Platform (GCP) # services must use one of the values listed in # this dns suffix property in their CSRs (comma separated list) 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 5a00fdd2f0f..7401b6735e0 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 @@ -96,6 +96,7 @@ public final class ZTSConsts { public static final String ZTS_PROP_ID_TOKEN_MAX_TIMEOUT = "athenz.zts.id_token_max_timeout"; public static final String ZTS_PROP_ID_TOKEN_DEFAULT_TIMEOUT = "athenz.zts.id_token_default_timeout"; public static final String ZTS_PROP_ID_TOKEN_MAX_DOMAINS = "athenz.zts.id_token_max_domains"; + public static final String ZTS_PROP_ACCESS_TOKEN_MAX_DOMAINS = "athenz.zts.access_token_max_domains"; public static final String ZTS_PROD_ID_TOKEN_ALLOWED_ROLES = "athenz.zts.id_token_allowed_roles"; public static final String ZTS_PROP_SIGNED_POLICY_TIMEOUT = "athenz.zts.signed_policy_timeout"; public static final String ZTS_PROP_AUTHORIZED_PROXY_USERS = "athenz.zts.authorized_proxy_users"; 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 ead540f1493..7902cd8676c 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 @@ -104,7 +104,6 @@ import java.nio.file.Paths; import java.security.cert.X509Certificate; import java.util.*; -import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -2888,7 +2887,7 @@ AccessTokenResponse processAccessTokenImpersonationRequest(ResourceContext ctx, AccessToken accessToken = new AccessToken(); accessToken.setVersion(1); - accessToken.setJwtId(UUID.randomUUID().toString()); + accessToken.setJwtId(java.util.UUID.randomUUID().toString()); accessToken.setAudience(requestDomainName); accessToken.setClientId(principalName); accessToken.setIssueTime(iat); @@ -2931,7 +2930,7 @@ String[] tokenExchangeRequestedRoles(AccessTokenRequest accessTokenRequest, OAut final String requestDomainName) { // first let's extract our scope claim from the subject token - // and convert that into a string array + // and convert that into a set Object scopeClaim = subjectToken.getClaim(AccessToken.CLAIM_SCOPE_STD); if (scopeClaim == null) { @@ -2939,28 +2938,53 @@ String[] tokenExchangeRequestedRoles(AccessTokenRequest accessTokenRequest, OAut return null; } - String[] scopeRoles = scopeClaim.toString().split(" "); + final String sourceDomainName = subjectToken.getAudience(); + if (StringUtil.isEmpty(sourceDomainName)) { + LOGGER.error("subject token does not contain a valid audience"); + return null; + } + final String scopeClaimStr = scopeClaim.toString().trim(); + if (scopeClaimStr.isEmpty()) { + LOGGER.error("subject token does not contain any scope roles"); + return null; + } + Set scopeRoles = new HashSet<>(Arrays.asList(scopeClaimStr.split("\\s+"))); final String scope = accessTokenRequest.getScope(); if (StringUtil.isEmpty(scope)) { - return scopeRoles; + String[] requestedRoles = getSubjectTokenRolesForDomain(scopeRoles, sourceDomainName, + requestDomainName); + if (requestedRoles == null || requestedRoles.length == 0) { + LOGGER.error("subject token does not contain any roles for requested domain {}", requestDomainName); + return null; + } + return requestedRoles; } - // if we have scope specified then we need to make sure the scope - // domain matches our subject token audience + // if we have scope specified then we need to make sure it includes + // roles from the requested target domain AccessTokenScope tokenScope = new AccessTokenScope(scope, null); - if (!requestDomainName.equals(tokenScope.getDomainName())) { - LOGGER.error("token scope domain {} does not match subject token audience {}", - tokenScope.getDomainName(), requestDomainName); + if (!tokenScope.getDomainNames().contains(requestDomainName)) { + LOGGER.error("token scope domains {} do not include requested domain {}", + tokenScope.getDomainNames(), requestDomainName); return null; } - // and the scope roles must be a subset of the subject token roles + // and the scope roles must be a subset of the subject token roles. + // Simple roles belong only to the subject token audience. Roles from + // any other domain must be fully qualified in the subject token. String[] requestedRoles = tokenScope.getRoleNames(requestDomainName); if (requestedRoles == null) { - return scopeRoles; - } else if (ZTSUtils.isSubset(requestedRoles, scopeRoles)) { + requestedRoles = getSubjectTokenRolesForDomain(scopeRoles, sourceDomainName, + requestDomainName); + if (requestedRoles == null || requestedRoles.length == 0) { + LOGGER.error("subject token does not contain any roles for requested domain {}", requestDomainName); + return null; + } + return requestedRoles; + } else if (subjectTokenHasRequestedRoles(scopeRoles, sourceDomainName, requestDomainName, + requestedRoles)) { return requestedRoles; } else { LOGGER.error("requested roles are not a subset of subject token roles"); @@ -2968,6 +2992,42 @@ String[] tokenExchangeRequestedRoles(AccessTokenRequest accessTokenRequest, OAut } } + boolean subjectTokenHasRequestedRoles(Set subjectScopes, final String sourceDomainName, + final String requestDomainName, String[] requestedRoles) { + + for (String requestedRole : requestedRoles) { + if (subjectScopes.contains(ResourceUtils.roleResourceName(requestDomainName, requestedRole))) { + continue; + } + if (requestDomainName.equals(sourceDomainName) && subjectScopes.contains(requestedRole)) { + continue; + } + return false; + } + return true; + } + + String[] getSubjectTokenRolesForDomain(Set subjectScopes, final String sourceDomainName, + final String requestDomainName) { + + Set requestedRoles = new HashSet<>(); + final String roleScopePrefix = requestDomainName + OAuthTokenScope.OBJECT_ROLE; + for (String subjectScope : subjectScopes) { + if (AccessTokenScope.OBJECT_OPENID.equals(subjectScope)) { + continue; + } + if (subjectScope.startsWith(roleScopePrefix)) { + String roleName = subjectScope.substring(roleScopePrefix.length()); + if (!roleName.isEmpty()) { + requestedRoles.add(roleName); + } + } else if (!subjectScope.contains(":") && requestDomainName.equals(sourceDomainName)) { + requestedRoles.add(subjectScope); + } + } + return requestedRoles.toArray(new String[0]); + } + AccessTokenResponse processAccessTokenDelegationRequest(ResourceContext ctx, Principal principal, OAuth2Token actorToken, AccessTokenRequest accessTokenRequest, final String principalDomain, final String caller) { @@ -3055,7 +3115,7 @@ AccessTokenResponse processAccessTokenDelegationRequest(ResourceContext ctx, Pri AccessToken accessToken = new AccessToken(); accessToken.setVersion(1); - accessToken.setJwtId(UUID.randomUUID().toString()); + accessToken.setJwtId(java.util.UUID.randomUUID().toString()); accessToken.setAudience(requestDomainName); accessToken.setClientId(principalName); accessToken.setIssueTime(iat); @@ -3119,6 +3179,33 @@ String generateScopeResponse(Set roles, final String domainName, boolean return sb.toString(); } + List generateAccessTokenScopeList(Map> rolesByDomain, final String audienceDomainName) { + + List scopes = new ArrayList<>(); + for (String domainName : new TreeSet<>(rolesByDomain.keySet())) { + for (String role : new TreeSet<>(rolesByDomain.get(domainName))) { + if (domainName.equals(audienceDomainName)) { + scopes.add(role); + } else { + scopes.add(domainName + OAuthTokenScope.OBJECT_ROLE + role); + } + } + } + return scopes; + } + + String generateScopeResponse(List scopes, boolean openIdScope) { + + StringBuilder sb = new StringBuilder(String.join(" ", scopes)); + if (openIdScope) { + if (sb.length() > 0) { + sb.append(" "); + } + sb.append(AccessTokenScope.OBJECT_OPENID); + } + return sb.toString(); + } + AccessTokenResponse processIdTokenExchangeRequest(ResourceContext ctx, Principal principal, AccessTokenRequest accessTokenRequest, final String principalDomain, final String caller) { @@ -3405,7 +3492,7 @@ AccessTokenResponse processJAGTokenIssueRequest(ResourceContext ctx, Principal p AccessToken accessToken = new AccessToken(); accessToken.setVersion(1); - accessToken.setJwtId(UUID.randomUUID().toString()); + accessToken.setJwtId(java.util.UUID.randomUUID().toString()); accessToken.setAudience(accessTokenRequest.getAudience()); accessToken.setClientId(principalName); accessToken.setIssueTime(iat); @@ -3646,7 +3733,7 @@ AccessTokenResponse processJAGTokenExchangeRequest(ResourceContext ctx, Principa AccessToken accessToken = new AccessToken(); accessToken.setVersion(1); - accessToken.setJwtId(UUID.randomUUID().toString()); + accessToken.setJwtId(java.util.UUID.randomUUID().toString()); accessToken.setAudience(audience); accessToken.setClientId(clientPrincipalName); accessToken.setIssueTime(iat); @@ -3731,103 +3818,140 @@ AccessTokenResponse processAccessTokenStandardRequest(ResourceContext ctx, Princ // before using any of our values let's validate that they // match our schema - final String domainName = tokenScope.getDomainName(); + Set scopeDomainNames = tokenScope.getDomainNames(); + if (scopeDomainNames.size() > 1 && StringUtil.isEmpty(accessTokenRequest.getAudience())) { + throw requestError("Multiple scope domains require an audience", caller, + ZTSConsts.ZTS_UNKNOWN_DOMAIN, principalDomain); + } + + final String domainName = StringUtil.isEmpty(accessTokenRequest.getAudience()) ? + tokenScope.getDomainName() : accessTokenRequest.getAudience().toLowerCase(); setRequestDomain(ctx, domainName); validate(domainName, TYPE_DOMAIN_NAME, principalDomain, caller); - - String[] requestedRoles = tokenScope.getRoleNames(domainName); - if (requestedRoles != null) { - for (String requestedRole : requestedRoles) { - validate(requestedRole, TYPE_ENTITY_NAME, principalDomain, caller); - } + if (!scopeDomainNames.contains(domainName)) { + throw requestError("Audience domain must be one of the scope domains", caller, + domainName, principalDomain); } - // first retrieve our domain data object from the cache - - DataCache data = dataStore.getDataCache(domainName); - if (data == null) { + DataCache audienceData = dataStore.getDataCache(domainName); + if (audienceData == null) { setRequestDomain(ctx, ZTSConsts.ZTS_UNKNOWN_DOMAIN); throw notFoundError("No such domain: " + domainName, caller, ZTSConsts.ZTS_UNKNOWN_DOMAIN, principalDomain); } + if (!StringUtil.isEmpty(accessTokenRequest.getAuthzDetails()) && scopeDomainNames.size() > 1) { + throw requestError("Authorization details cannot be requested for multiple domains", caller, + domainName, principalDomain); + } + // if we're given authorization details to be included in the // token then we must have only role requested and we need // to make sure the requested fields are valid according // to our configured authorization details entity for the role - validateAuthorizationDetails(accessTokenRequest.getAuthzDetails(), requestedRoles, data, caller, - domainName, principalDomain); + String proxyUser = null; + final String requestPrincipalName = principalName; + final boolean proxyForPrincipal = !StringUtil.isEmpty(accessTokenRequest.getProxyForPrincipal()); + if (proxyForPrincipal) { - // check if the authorized service domain matches to the - // requested domain name + // we also need to verify that we are not returning id tokens. + // proxy principal functionality is only valid for access tokens - checkRoleTokenAuthorizedServiceRequest(principal, domainName, caller); + if (tokenScope.isOpenIdScope()) { + throw requestError("Proxy Principal cannot request id tokens", caller, + domainName, principalDomain); + } - // process our request and retrieve the roles for the principal + proxyUser = principalName; + principalName = accessTokenRequest.getProxyForPrincipal(); + } - Set roles = new HashSet<>(); - dataStore.getAccessibleRoles(data, domainName, principalName, requestedRoles, false, roles, false); + Map> rolesByDomain = new HashMap<>(); + int tokenTimeout = Integer.MAX_VALUE; + boolean scopeAdjusted = false; + for (String scopeDomainName : scopeDomainNames) { - // we return failure if we don't have access to any roles + validate(scopeDomainName, TYPE_DOMAIN_NAME, principalDomain, caller); - if (roles.isEmpty()) { - throw forbiddenError(tokenErrorMessage(caller, principalName, domainName, requestedRoles), - caller, domainName, principalDomain); - } + String[] requestedRoles = tokenScope.getRoleNames(scopeDomainName); + if (requestedRoles != null) { + for (String requestedRole : requestedRoles) { + validate(requestedRole, TYPE_ENTITY_NAME, principalDomain, caller); + } + } - // if this is proxy for operation then we want to make sure that - // both principals have access to the same set of roles so we'll - // remove any roles that are authorized by only one of the principals + // first retrieve our domain data object from the cache - String proxyUser = null; - if (!StringUtil.isEmpty(accessTokenRequest.getProxyForPrincipal())) { + DataCache data = dataStore.getDataCache(scopeDomainName); + if (data == null) { + setRequestDomain(ctx, ZTSConsts.ZTS_UNKNOWN_DOMAIN); + throw notFoundError("No such domain: " + scopeDomainName, caller, + ZTSConsts.ZTS_UNKNOWN_DOMAIN, principalDomain); + } - // we also need to verify that we are not returning id tokens. - // proxy principal functionality is only valid for access tokens + validateAuthorizationDetails(accessTokenRequest.getAuthzDetails(), requestedRoles, data, caller, + scopeDomainName, principalDomain); - if (tokenScope.isOpenIdScope()) { - throw requestError("Proxy Principal cannot request id tokens", caller, - domainName, principalDomain); - } + // check if the authorized service domain matches to the + // requested domain name - // process the role lookup for the proxy principal + checkRoleTokenAuthorizedServiceRequest(principal, scopeDomainName, caller); - Set rolesForProxy = new HashSet<>(); - dataStore.getAccessibleRoles(data, domainName, accessTokenRequest.getProxyForPrincipal(), requestedRoles, - false, rolesForProxy, false); - roles.retainAll(rolesForProxy); + // process our request and retrieve the roles for the principal - // check again in case we removed all the roles and ended up - // with an empty set + Set roles = new HashSet<>(); + dataStore.getAccessibleRoles(data, scopeDomainName, requestPrincipalName, requestedRoles, false, roles, false); + + // we return failure if we don't have access to any roles if (roles.isEmpty()) { - throw forbiddenError(tokenErrorMessage(caller, accessTokenRequest.getProxyForPrincipal(), domainName, - requestedRoles), caller, domainName, principalDomain); + throw forbiddenError(tokenErrorMessage(caller, requestPrincipalName, scopeDomainName, requestedRoles), + caller, scopeDomainName, principalDomain); } - // we need to switch our principal and proxy for user + // if this is proxy for operation then we want to make sure that + // both principals have access to the same set of roles so we'll + // remove any roles that are authorized by only one of the principals - proxyUser = principalName; - principalName = accessTokenRequest.getProxyForPrincipal(); - } + if (proxyForPrincipal) { + Set rolesForProxy = new HashSet<>(); + dataStore.getAccessibleRoles(data, scopeDomainName, accessTokenRequest.getProxyForPrincipal(), requestedRoles, + false, rolesForProxy, false); + roles.retainAll(rolesForProxy); - // if the request was done by a role certificate we need to make sure - // that it is issued for the roles we're returning in the role token + // check again in case we removed all the roles and ended up + // with an empty set - if (!isPrincipalRoleCertificateAccessValid(principal, domainName, roles)) { - throw forbiddenError("Role based Principal does not include all roles", - caller, domainName, principalDomain); + if (roles.isEmpty()) { + throw forbiddenError(tokenErrorMessage(caller, accessTokenRequest.getProxyForPrincipal(), scopeDomainName, + requestedRoles), caller, scopeDomainName, principalDomain); + } + } + + // if the request was done by a role certificate we need to make sure + // that it is issued for the roles we're returning in the role token + + if (!isPrincipalRoleCertificateAccessValid(principal, scopeDomainName, roles)) { + throw forbiddenError("Role based Principal does not include all roles", + caller, scopeDomainName, principalDomain); + } + + rolesByDomain.put(scopeDomainName, roles); + int domainTokenTimeout = determineTokenTimeout(data, roles, null, accessTokenRequest.getExpiryTime()); + tokenTimeout = Math.min(tokenTimeout, domainTokenTimeout); + if (tokenScope.sendScopeResponse() || requestedRoles != null && requestedRoles.length != roles.size()) { + scopeAdjusted = true; + } } - List roleList = new ArrayList<>(roles); - int tokenTimeout = determineTokenTimeout(data, roles, null, accessTokenRequest.getExpiryTime()); long iat = System.currentTimeMillis() / 1000; + List tokenScopes = generateAccessTokenScopeList(rolesByDomain, domainName); AccessToken accessToken = new AccessToken(); accessToken.setVersion(1); - accessToken.setJwtId(UUID.randomUUID().toString()); - accessToken.setAudience(getAccessTokenAudience(domainName, accessTokenRequest.isRoleInAudClaim(), roleList)); + accessToken.setJwtId(java.util.UUID.randomUUID().toString()); + accessToken.setAudience(getAccessTokenAudience(domainName, accessTokenRequest.isRoleInAudClaim(), tokenScopes)); accessToken.setClientId(principalName); accessToken.setIssueTime(iat); accessToken.setAuthTime(iat); @@ -3836,7 +3960,7 @@ AccessTokenResponse processAccessTokenStandardRequest(ResourceContext ctx, Princ accessToken.setSubject(principalName); accessToken.setIssuer(issuerResolver.getAccessTokenIssuer(ctx.request(), accessTokenRequest.isUseOpenIDIssuer())); accessToken.setProxyPrincipal(proxyUser); - accessToken.setScope(roleList); + accessToken.setScope(tokenScopes); accessToken.setAuthorizationDetails(accessTokenRequest.getAuthzDetails()); accessToken.setPrincipalIssuer(principal.getIssuerIdentity()); @@ -3893,8 +4017,14 @@ AccessTokenResponse processAccessTokenStandardRequest(ResourceContext ctx, Princ // does not match the returned list of roles then we need to return the updated // set of scopes - if (tokenScope.sendScopeResponse() || requestedRoles != null && requestedRoles.length != roles.size()) { - response.setScope(generateScopeResponse(roles, domainName, tokenScope.isOpenIdScope())); + if (scopeAdjusted || scopeDomainNames.size() > 1 || !scopeDomainNames.contains(domainName)) { + if (scopeDomainNames.size() == 1) { + final String scopeDomainName = scopeDomainNames.iterator().next(); + response.setScope(generateScopeResponse(rolesByDomain.get(scopeDomainName), + scopeDomainName, tokenScope.isOpenIdScope())); + } else { + response.setScope(generateScopeResponse(tokenScopes, tokenScope.isOpenIdScope())); + } } return response; diff --git a/servers/zts/src/main/java/com/yahoo/athenz/zts/token/AccessTokenScope.java b/servers/zts/src/main/java/com/yahoo/athenz/zts/token/AccessTokenScope.java index f6e448e09f6..c211f7c00b2 100644 --- a/servers/zts/src/main/java/com/yahoo/athenz/zts/token/AccessTokenScope.java +++ b/servers/zts/src/main/java/com/yahoo/athenz/zts/token/AccessTokenScope.java @@ -24,6 +24,8 @@ public class AccessTokenScope extends OAuthTokenScope { System.getProperty(ZTSConsts.ZTS_PROP_OAUTH_OPENID_SCOPE, "false")); private static boolean supportRolesWithoutDomain = Boolean.parseBoolean( System.getProperty(ZTSConsts.ZTS_PROP_SCOPE_ROLE_WOUT_DOMAIN, "false")); + private static int maxDomains = Integer.parseInt( + System.getProperty(ZTSConsts.ZTS_PROP_ACCESS_TOKEN_MAX_DOMAINS, "1")); public AccessTokenScope(final String scope, final String principalDomain) { @@ -34,11 +36,11 @@ public AccessTokenScope(final String scope, final String principalDomain) { // :role. // openid :service. - super(scope, 1, null, supportRolesWithoutDomain ? principalDomain : null); + super(scope, maxDomains, null, supportRolesWithoutDomain ? principalDomain : null); // if we don't have a domain then it's invalid scope - if (StringUtil.isEmpty(getDomainName())) { + if (getDomainNames().isEmpty()) { throw error("No domains in scope", scope); } @@ -49,6 +51,15 @@ public AccessTokenScope(final String scope, final String principalDomain) { if (openIdScope && StringUtil.isEmpty(serviceName)) { throw error("No audience service name for openid scope", scope); } + + if (openIdScope && getDomainName() == null) { + throw error("Multiple domains not supported with openid scope", scope); + } + } + + @Override + public String getDomainName() { + return (domainNames.size() == 1) ? domainNames.iterator().next() : null; } @Override @@ -63,4 +74,8 @@ public static void setSupportOpenIdScope(boolean value) { public static void setSupportRolesWithoutDomain(boolean value) { supportRolesWithoutDomain = value; } + + public static void setMaxDomains(int numDomains) { + maxDomains = numDomains; + } } diff --git a/servers/zts/src/main/java/com/yahoo/athenz/zts/token/OAuthTokenScope.java b/servers/zts/src/main/java/com/yahoo/athenz/zts/token/OAuthTokenScope.java index 0221709497b..8e2fd369340 100644 --- a/servers/zts/src/main/java/com/yahoo/athenz/zts/token/OAuthTokenScope.java +++ b/servers/zts/src/main/java/com/yahoo/athenz/zts/token/OAuthTokenScope.java @@ -69,6 +69,7 @@ public OAuthTokenScope(final String scope, int maxDomains, DynamicConfigCsv syst Set userSystemAllowedRoles = null; Map> scopeRoleNames = new HashMap<>(); Map> scopeGroupNames = new HashMap<>(); + Set domainScopeNames = null; for (String scopeItem : scopeList) { // first check if we have an openid scope requested @@ -103,6 +104,10 @@ public OAuthTokenScope(final String scope, int maxDomains, DynamicConfigCsv syst if (scopeItem.endsWith(OBJECT_DOMAIN)) { final String scopeDomainName = scopeItem.substring(0, scopeItem.length() - OBJECT_DOMAIN.length()); addScopeDomain(scopeDomainName, scope, true); + if (domainScopeNames == null) { + domainScopeNames = new HashSet<>(); + } + domainScopeNames.add(scopeDomainName); sendScopeResponse = true; continue; } @@ -168,31 +173,29 @@ public OAuthTokenScope(final String scope, int maxDomains, DynamicConfigCsv syst } } - // if the scope response is set to true then we had - // an explicit request for all roles or groups in the domain - // then we're going to ignore the role and groups names requested, - // but we still need to set the role/group scope in case - // some role or group name was passed without the explicit scope + // Keep explicit role and group filters for every domain. A domain + // scope only overrides filters for that specific domain. - if (!sendScopeResponse) { - if (!scopeRoleNames.isEmpty()) { - roleNames = scopeRoleNames; - } - if (!scopeGroupNames.isEmpty()) { - groupNames = scopeGroupNames; - } - } else { - if (!scopeRoleNames.isEmpty()) { - rolesScope = true; - } - if (!scopeGroupNames.isEmpty()) { - groupsScope = true; + if (sendScopeResponse) { + rolesScope = rolesScope || !scopeRoleNames.isEmpty(); + groupsScope = groupsScope || !scopeGroupNames.isEmpty(); + } + if (domainScopeNames != null) { + for (String domainScopeName : domainScopeNames) { + scopeRoleNames.remove(domainScopeName); + scopeGroupNames.remove(domainScopeName); } } + if (!scopeRoleNames.isEmpty()) { + roleNames = scopeRoleNames; + } + if (!scopeGroupNames.isEmpty()) { + groupNames = scopeGroupNames; + } } public String getDomainName() { - return (maxDomains == 1 && !domainNames.isEmpty()) ? domainNames.stream().findFirst().get() : null; + return (maxDomains == 1 && domainNames.size() == 1) ? domainNames.iterator().next() : null; } public Set getDomainNames() { @@ -238,12 +241,11 @@ void addScopeDomain(final String scopeDomainName, final String scope, boolean en if (scopeDomainName.isEmpty()) { throw error("empty domain name", scope); } - final String domainName = getDomainName(); - if (enforceMaxDomainCheck && domainName != null && !scopeDomainName.equals(domainName)) { - throw error("Multiple domains in scope", scope); - } if (!domainNames.contains(scopeDomainName)) { - if (enforceMaxDomainCheck && domainNames.size() == maxDomains) { + if (enforceMaxDomainCheck && domainNames.size() >= maxDomains) { + if (maxDomains == 1) { + throw error("Multiple domains in scope", scope); + } throw error("Domain limit: " + maxDomains + " has been reached", scope); } domainNames.add(scopeDomainName); 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 26babf91f70..4219c425c1e 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 @@ -135,6 +135,8 @@ public void setup() { ZTSTestUtils.deleteDirectory(new File(ZTS_DATA_STORE_PATH)); + System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, + "src/test/resources/unit_test_zts_private.pem"); String privKeyName = System.getProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY); File privKeyFile = new File(privKeyName); String privKey = Crypto.encodedFile(privKeyFile); @@ -179,6 +181,7 @@ public void setup() { // enable openid scope AccessTokenScope.setSupportOpenIdScope(true); + AccessTokenScope.setMaxDomains(1); } @AfterMethod @@ -187,6 +190,7 @@ public void shutdown() { ZTSTestUtils.deleteDirectory(new File(ZTS_DATA_STORE_PATH)); System.clearProperty(ZTSConsts.ZTS_PROP_ROLE_TOKEN_MAX_TIMEOUT); System.clearProperty(ZTSConsts.ZTS_PROP_ROLE_TOKEN_DEFAULT_TIMEOUT); + AccessTokenScope.setMaxDomains(1); } private ConfigurableJWTProcessor createJAGProcessor() { @@ -411,6 +415,22 @@ private SignedDomain createSignedDomain(String domainName, String tenantDomain, return signedDomain; } + private void addRoleMemberToSignedDomain(SignedDomain signedDomain, final String domainName, + final String roleName, final String memberName) { + + DomainData domainData = signedDomain.getDomain(); + List roles = domainData.getRoles() == null ? new ArrayList<>() : new ArrayList<>(domainData.getRoles()); + + Role role = new Role(); + role.setName(generateRoleName(domainName, roleName)); + role.setRoleMembers(Collections.singletonList(new RoleMember().setMemberName(memberName))); + roles.add(role); + + domainData.setRoles(roles); + signedDomain.setDomain(domainData); + signedDomain.setSignature(Crypto.sign(SignUtils.asCanonicalString(domainData), privateKey)); + } + private List createServices(String domainName, String serviceName) { List services = new ArrayList<>(); ServiceIdentity service = new ServiceIdentity(); @@ -536,6 +556,162 @@ public void testPostAccessTokenRequest() throws JOSEException { } } + @Test + public void testPostAccessTokenRequestWithAudienceAndCrossDomainScope() throws JOSEException { + + AccessTokenScope.setMaxDomains(20); + 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); + // set back to our zts rsa private key + System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_private.pem"); + + SignedDomain mcpHubDomain = createSignedDomain("mcp-hub", "weather", "storage", true); + addRoleMemberToSignedDomain(mcpHubDomain, "mcp-hub", "api-mcp-accessor", "user_domain.user"); + store.processSignedDomain(mcpHubDomain, false); + + SignedDomain apiDomain = createSignedDomain("api", "weather", "storage", true); + addRoleMemberToSignedDomain(apiDomain, "api", "docs-getter", "user_domain.user"); + store.processSignedDomain(apiDomain, false); + + Principal principal = SimplePrincipal.create("user_domain", "user", + "v=U1;d=user_domain;n=user;s=signature", 0, null); + ResourceContext context = createResourceContext(principal); + + final String scope = URLEncoder.encode("mcp-hub:role.api-mcp-accessor api:role.docs-getter", + StandardCharsets.UTF_8); + AccessTokenResponse resp = ztsImpl.postAccessTokenRequest(context, + "grant_type=client_credentials&audience=mcp-hub&scope=" + scope); + assertNotNull(resp); + assertEquals(resp.getScope(), "api:role.docs-getter api-mcp-accessor"); + + String accessTokenStr = resp.getAccess_token(); + assertNotNull(accessTokenStr); + + ServerPrivateKey privateKey = getServerPrivateKey(ztsImpl, ztsImpl.keyAlgoForJsonWebObjects); + JWSVerifier verifier = JwtsHelper.getJWSVerifier(Crypto.extractPublicKey(privateKey.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), "mcp-hub"); + assertEquals(claimSet.getStringClaim("scope"), "api:role.docs-getter api-mcp-accessor"); + assertEquals(ztsImpl.ztsOAuthIssuer, claimSet.getIssuer()); + + List scopes = claimSet.getStringListClaim("scp"); + assertNotNull(scopes); + assertEquals(scopes.size(), 2); + assertTrue(scopes.contains("api-mcp-accessor")); + assertTrue(scopes.contains("api:role.docs-getter")); + } catch (Exception ex) { + fail(ex.getMessage()); + } + } + + @Test + public void testPostAccessTokenRequestDomainScopeDoesNotWidenOtherDomainRoles() throws JOSEException { + + AccessTokenScope.setMaxDomains(20); + 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); + System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_private.pem"); + + store.processSignedDomain(createSignedDomain("sports", "weather", "storage", true), false); + store.processSignedDomain(createSignedDomain("weather", "sports", "storage", true), false); + + Principal principal = SimplePrincipal.create("user_domain", "user1", + "v=U1;d=user_domain;n=user1;s=signature", 0, null); + ResourceContext context = createResourceContext(principal); + + final String scope = URLEncoder.encode("sports:domain weather:role.readers", StandardCharsets.UTF_8); + AccessTokenResponse resp = ztsImpl.postAccessTokenRequest(context, + "grant_type=client_credentials&audience=sports&scope=" + scope); + + assertNotNull(resp); + assertEquals(resp.getScope(), "readers writers weather:role.readers"); + + ServerPrivateKey privateKey = getServerPrivateKey(ztsImpl, ztsImpl.keyAlgoForJsonWebObjects); + JWSVerifier verifier = JwtsHelper.getJWSVerifier(Crypto.extractPublicKey(privateKey.getKey())); + try { + SignedJWT signedJWT = SignedJWT.parse(resp.getAccess_token()); + assertTrue(signedJWT.verify(verifier)); + JWTClaimsSet claimSet = signedJWT.getJWTClaimsSet(); + + assertEquals(claimSet.getStringClaim(AccessToken.CLAIM_SCOPE_STD), + "readers writers weather:role.readers"); + List scopes = claimSet.getStringListClaim(AccessToken.CLAIM_SCOPE); + assertEquals(scopes.size(), 3); + assertTrue(scopes.contains("readers")); + assertTrue(scopes.contains("writers")); + assertTrue(scopes.contains("weather:role.readers")); + assertFalse(scopes.contains("weather:role.writers")); + } catch (Exception ex) { + fail(ex.getMessage()); + } + } + + @Test + public void testPostAccessTokenRequestAudienceMustBeScopeDomain() { + + AccessTokenScope.setMaxDomains(20); + 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); + + Principal principal = SimplePrincipal.create("user_domain", "user1", + "v=U1;d=user_domain;n=user1;s=signature", 0, null); + ResourceContext context = createResourceContext(principal); + + try { + ztsImpl.postAccessTokenRequest(context, + "grant_type=client_credentials&audience=sports&scope=weather:role.readers"); + fail(); + } catch (ResourceException ex) { + assertEquals(ex.getCode(), ResourceException.BAD_REQUEST); + assertTrue(ex.getMessage().contains("Audience domain must be one of the scope domains")); + } + + cloudStore.close(); + } + + @Test + public void testPostAccessTokenRequestMultipleScopeDomainsScopeDomainNotFound() { + + AccessTokenScope.setMaxDomains(20); + 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); + System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_private.pem"); + + SignedDomain mcpHubDomain = createSignedDomain("mcp-hub", "weather", "storage", true); + addRoleMemberToSignedDomain(mcpHubDomain, "mcp-hub", "api-mcp-accessor", "user_domain.user"); + store.processSignedDomain(mcpHubDomain, false); + + Principal principal = SimplePrincipal.create("user_domain", "user", + "v=U1;d=user_domain;n=user;s=signature", 0, null); + ResourceContext context = createResourceContext(principal); + + final String scope = URLEncoder.encode("mcp-hub:role.api-mcp-accessor api:role.docs-getter", + StandardCharsets.UTF_8); + try { + ztsImpl.postAccessTokenRequest(context, "grant_type=client_credentials&audience=mcp-hub&scope=" + scope); + fail(); + } catch (ResourceException ex) { + assertEquals(ex.getCode(), ResourceException.NOT_FOUND); + assertTrue(ex.getMessage().contains("No such domain: api")); + } + cloudStore.close(); + } + @Test public void testPostAccessTokenRequestEmptyProxyPrincipal() { @@ -4494,6 +4670,82 @@ public void testGenerateScopeResponseNullDomain() { assertEquals(result, "null:role.admin"); } + @Test + public void testGenerateAccessTokenScopeListMultipleDomains() { + + Map> rolesByDomain = new HashMap<>(); + rolesByDomain.put("mcp-hub", new HashSet<>(Arrays.asList("api-mcp-accessor"))); + rolesByDomain.put("api", new HashSet<>(Arrays.asList("docs-getter"))); + rolesByDomain.put("weather", new HashSet<>(Arrays.asList("reader"))); + + List scopes = zts.generateAccessTokenScopeList(rolesByDomain, "mcp-hub"); + + assertEquals(scopes, Arrays.asList( + "api:role.docs-getter", + "api-mcp-accessor", + "weather:role.reader" + )); + + assertEquals( + zts.generateScopeResponse(scopes, false), + "api:role.docs-getter api-mcp-accessor weather:role.reader" + ); + + assertEquals( + zts.generateScopeResponse(scopes, true), + "api:role.docs-getter api-mcp-accessor weather:role.reader openid" + ); + assertEquals(zts.generateScopeResponse(Collections.emptyList(), true), "openid"); + } + + @Test + public void testSubjectTokenHasRequestedRolesWithFullyQualifiedScopes() { + Set subjectScopes = new HashSet<>(Arrays.asList( + "api-mcp-accessor", + "api:role.docs-getter", + "weather:role.reader", + "openid" + )); + + assertTrue(zts.subjectTokenHasRequestedRoles( + subjectScopes, "mcp-hub", "api", new String[] { "docs-getter" })); + assertFalse(zts.subjectTokenHasRequestedRoles( + subjectScopes, "mcp-hub", "api", new String[] { "admin" })); + assertTrue(zts.subjectTokenHasRequestedRoles( + subjectScopes, "mcp-hub", "mcp-hub", new String[] { "api-mcp-accessor" })); + assertFalse(zts.subjectTokenHasRequestedRoles( + subjectScopes, "mcp-hub", "api", new String[] { "api-mcp-accessor" })); + assertTrue(zts.subjectTokenHasRequestedRoles( + subjectScopes, "mcp-hub", "api", new String[] { "docs-getter" })); + } + + @Test + public void testGetSubjectTokenRolesForDomainFullyQualifiedScopes() { + Set subjectScopes = new HashSet<>(Arrays.asList( + "api-mcp-accessor", + "api:role.docs-getter", + "api:role.", + "weather:role.reader", + "openid" + )); + + String[] apiRoles = zts.getSubjectTokenRolesForDomain(subjectScopes, "mcp-hub", "api"); + assertEquals(apiRoles.length, 1); + assertEquals(apiRoles[0], "docs-getter"); + + String[] sourceRoles = zts.getSubjectTokenRolesForDomain(subjectScopes, "mcp-hub", "mcp-hub"); + assertEquals(sourceRoles.length, 1); + assertEquals(sourceRoles[0], "api-mcp-accessor"); + } + + @Test + public void testGetSubjectTokenRolesForDomainSimpleRoleDoesNotCrossDomains() { + Set subjectScopes = new HashSet<>(Arrays.asList("docs-getter")); + + String[] roles = zts.getSubjectTokenRolesForDomain(subjectScopes, "mcp-hub", "api"); + assertEquals(roles.length, 0); + } + @Test public void testTokenExchangeRequestedRolesNullScopeClaim() { AccessTokenRequest accessTokenRequest = Mockito.mock(AccessTokenRequest.class); @@ -4506,12 +4758,25 @@ public void testTokenExchangeRequestedRolesNullScopeClaim() { assertNull(result); } + @Test + public void testTokenExchangeRequestedRolesMissingAudience() { + AccessTokenRequest accessTokenRequest = Mockito.mock(AccessTokenRequest.class); + OAuth2Token subjectToken = Mockito.mock(OAuth2Token.class); + String requestDomainName = "testdomain"; + + Mockito.when(subjectToken.getClaim(AccessToken.CLAIM_SCOPE_STD)).thenReturn("admin writer"); + Mockito.when(accessTokenRequest.getScope()).thenReturn("testdomain:role.admin"); + + assertNull(zts.tokenExchangeRequestedRoles(accessTokenRequest, subjectToken, requestDomainName)); + } + @Test public void testTokenExchangeRequestedRolesEmptyScopeInRequest() { AccessTokenRequest accessTokenRequest = Mockito.mock(AccessTokenRequest.class); OAuth2Token subjectToken = Mockito.mock(OAuth2Token.class); String requestDomainName = "testdomain"; + Mockito.when(subjectToken.getAudience()).thenReturn(requestDomainName); Mockito.when(subjectToken.getClaim(AccessToken.CLAIM_SCOPE_STD)).thenReturn("admin writer"); Mockito.when(accessTokenRequest.getScope()).thenReturn(""); @@ -4530,6 +4795,7 @@ public void testTokenExchangeRequestedRolesNullScopeInRequest() { OAuth2Token subjectToken = Mockito.mock(OAuth2Token.class); String requestDomainName = "testdomain"; + Mockito.when(subjectToken.getAudience()).thenReturn(requestDomainName); Mockito.when(subjectToken.getClaim(AccessToken.CLAIM_SCOPE_STD)).thenReturn("admin writer"); Mockito.when(accessTokenRequest.getScope()).thenReturn(null); @@ -4541,6 +4807,22 @@ public void testTokenExchangeRequestedRolesNullScopeInRequest() { assertTrue(resultSet.contains("writer")); } + @Test + public void testTokenExchangeRequestedRolesTrimSubjectTokenScope() { + AccessTokenRequest accessTokenRequest = Mockito.mock(AccessTokenRequest.class); + OAuth2Token subjectToken = Mockito.mock(OAuth2Token.class); + String requestDomainName = "testdomain"; + + Mockito.when(subjectToken.getAudience()).thenReturn(requestDomainName); + Mockito.when(subjectToken.getClaim(AccessToken.CLAIM_SCOPE_STD)).thenReturn(" admin writer "); + Mockito.when(accessTokenRequest.getScope()).thenReturn("testdomain:role.admin"); + + String[] result = zts.tokenExchangeRequestedRoles(accessTokenRequest, subjectToken, requestDomainName); + assertNotNull(result); + assertEquals(result.length, 1); + assertEquals(result[0], "admin"); + } + @Test public void testTokenExchangeRequestedRolesValidSubset() { AccessTokenRequest accessTokenRequest = Mockito.mock(AccessTokenRequest.class); @@ -4548,6 +4830,7 @@ public void testTokenExchangeRequestedRolesValidSubset() { String requestDomainName = "testdomain"; // Subject token has: admin, writer, reader + Mockito.when(subjectToken.getAudience()).thenReturn(requestDomainName); Mockito.when(subjectToken.getClaim(AccessToken.CLAIM_SCOPE_STD)).thenReturn("admin writer reader"); // Request only: admin, writer (subset) Mockito.when(accessTokenRequest.getScope()).thenReturn("testdomain:role.admin testdomain:role.writer"); @@ -4567,6 +4850,7 @@ public void testTokenExchangeRequestedRolesExactMatch() { OAuth2Token subjectToken = Mockito.mock(OAuth2Token.class); String requestDomainName = "testdomain"; + Mockito.when(subjectToken.getAudience()).thenReturn(requestDomainName); Mockito.when(subjectToken.getClaim(AccessToken.CLAIM_SCOPE_STD)).thenReturn("admin writer"); Mockito.when(accessTokenRequest.getScope()).thenReturn("testdomain:role.admin testdomain:role.writer"); @@ -4585,6 +4869,7 @@ public void testTokenExchangeRequestedRolesNotSubset() { String requestDomainName = "testdomain"; // Subject token has: admin, writer + Mockito.when(subjectToken.getAudience()).thenReturn(requestDomainName); Mockito.when(subjectToken.getClaim(AccessToken.CLAIM_SCOPE_STD)).thenReturn("admin writer"); // Request: admin, writer, reader (not a subset) Mockito.when(accessTokenRequest.getScope()).thenReturn("testdomain:role.admin testdomain:role.writer testdomain:role.reader"); @@ -4599,6 +4884,7 @@ public void testTokenExchangeRequestedRolesDomainMismatch() { OAuth2Token subjectToken = Mockito.mock(OAuth2Token.class); String requestDomainName = "testdomain"; + Mockito.when(subjectToken.getAudience()).thenReturn(requestDomainName); Mockito.when(subjectToken.getClaim(AccessToken.CLAIM_SCOPE_STD)).thenReturn("admin"); // Request domain is different Mockito.when(accessTokenRequest.getScope()).thenReturn("otherdomain:role.admin"); @@ -4607,12 +4893,117 @@ public void testTokenExchangeRequestedRolesDomainMismatch() { assertNull(result); } + @Test + public void testTokenExchangeRequestedRolesCrossDomainScope() { + AccessTokenRequest accessTokenRequest = Mockito.mock(AccessTokenRequest.class); + OAuth2Token subjectToken = Mockito.mock(OAuth2Token.class); + String requestDomainName = "api"; + + Mockito.when(subjectToken.getAudience()).thenReturn("mcp-hub"); + Mockito.when(subjectToken.getClaim(AccessToken.CLAIM_SCOPE_STD)) + .thenReturn("api-mcp-accessor api:role.docs-getter"); + Mockito.when(accessTokenRequest.getScope()).thenReturn("api:role.docs-getter"); + + String[] result = zts.tokenExchangeRequestedRoles(accessTokenRequest, subjectToken, requestDomainName); + assertNotNull(result); + assertEquals(result.length, 1); + assertEquals(result[0], "docs-getter"); + } + + @Test + public void testTokenExchangeRequestedRolesFromSubjectTokenScope() { + AccessTokenRequest accessTokenRequest = Mockito.mock(AccessTokenRequest.class); + OAuth2Token subjectToken = Mockito.mock(OAuth2Token.class); + + Mockito.when(accessTokenRequest.getScope()).thenReturn(null); + Mockito.when(subjectToken.getClaim(AccessToken.CLAIM_SCOPE_STD)) + .thenReturn("api-mcp-accessor api:role.docs-getter openid"); + Mockito.when(subjectToken.getAudience()).thenReturn("mcp-hub"); + + String[] requestedRoles = zts.tokenExchangeRequestedRoles(accessTokenRequest, subjectToken, "api"); + assertNotNull(requestedRoles); + assertEquals(requestedRoles.length, 1); + assertEquals(requestedRoles[0], "docs-getter"); + } + + @Test + public void testTokenExchangeRequestedRolesFromSubjectTokenScopeNoTargetRoles() { + AccessTokenRequest accessTokenRequest = Mockito.mock(AccessTokenRequest.class); + OAuth2Token subjectToken = Mockito.mock(OAuth2Token.class); + + Mockito.when(accessTokenRequest.getScope()).thenReturn(null); + Mockito.when(subjectToken.getClaim(AccessToken.CLAIM_SCOPE_STD)) + .thenReturn("api-mcp-accessor weather:role.reader openid"); + Mockito.when(subjectToken.getAudience()).thenReturn("mcp-hub"); + + String[] requestedRoles = zts.tokenExchangeRequestedRoles(accessTokenRequest, subjectToken, "api"); + assertNull(requestedRoles); + } + + @Test + public void testTokenExchangeRequestedRolesExplicitScopeNoTargetRolesInSubjectToken() { + AccessTokenRequest accessTokenRequest = Mockito.mock(AccessTokenRequest.class); + OAuth2Token subjectToken = Mockito.mock(OAuth2Token.class); + + Mockito.when(accessTokenRequest.getScope()).thenReturn("api:domain"); + Mockito.when(subjectToken.getClaim(AccessToken.CLAIM_SCOPE_STD)) + .thenReturn("api-mcp-accessor weather:role.reader openid"); + Mockito.when(subjectToken.getAudience()).thenReturn("mcp-hub"); + + String[] requestedRoles = zts.tokenExchangeRequestedRoles(accessTokenRequest, subjectToken, "api"); + assertNull(requestedRoles); + } + + @Test + public void testTokenExchangeRequestedRolesIgnoresNonRoleScopes() { + AccessTokenRequest accessTokenRequest = Mockito.mock(AccessTokenRequest.class); + OAuth2Token subjectToken = Mockito.mock(OAuth2Token.class); + String requestDomainName = "api"; + + Mockito.when(subjectToken.getAudience()).thenReturn("api"); + Mockito.when(subjectToken.getClaim(AccessToken.CLAIM_SCOPE_STD)) + .thenReturn("openid api:service.backend api:role.docs-getter"); + Mockito.when(accessTokenRequest.getScope()).thenReturn("api:domain"); + + String[] result = zts.tokenExchangeRequestedRoles(accessTokenRequest, subjectToken, requestDomainName); + assertNotNull(result); + assertEquals(result.length, 1); + assertEquals(result[0], "docs-getter"); + } + + @Test + public void testTokenExchangeRequestedRolesSimpleRoleCannotCrossDomains() { + AccessTokenRequest accessTokenRequest = Mockito.mock(AccessTokenRequest.class); + OAuth2Token subjectToken = Mockito.mock(OAuth2Token.class); + String requestDomainName = "api"; + + Mockito.when(subjectToken.getAudience()).thenReturn("mcp-hub"); + Mockito.when(subjectToken.getClaim(AccessToken.CLAIM_SCOPE_STD)).thenReturn("docs-getter"); + Mockito.when(accessTokenRequest.getScope()).thenReturn("api:role.docs-getter"); + + String[] result = zts.tokenExchangeRequestedRoles(accessTokenRequest, subjectToken, requestDomainName); + assertNull(result); + } + + @Test + public void testTokenExchangeRequestedRolesNoScopeDoesNotInferCrossDomainRole() { + AccessTokenRequest accessTokenRequest = Mockito.mock(AccessTokenRequest.class); + OAuth2Token subjectToken = Mockito.mock(OAuth2Token.class); + + Mockito.when(subjectToken.getAudience()).thenReturn("mcp-hub"); + Mockito.when(subjectToken.getClaim(AccessToken.CLAIM_SCOPE_STD)).thenReturn("docs-getter"); + Mockito.when(accessTokenRequest.getScope()).thenReturn(null); + + assertNull(zts.tokenExchangeRequestedRoles(accessTokenRequest, subjectToken, "api")); + } + @Test public void testTokenExchangeRequestedRolesNoRequestedRoles() { AccessTokenRequest accessTokenRequest = Mockito.mock(AccessTokenRequest.class); OAuth2Token subjectToken = Mockito.mock(OAuth2Token.class); String requestDomainName = "testdomain"; + Mockito.when(subjectToken.getAudience()).thenReturn(requestDomainName); Mockito.when(subjectToken.getClaim(AccessToken.CLAIM_SCOPE_STD)).thenReturn("admin writer"); // Request has domain scope but no specific roles Mockito.when(accessTokenRequest.getScope()).thenReturn("testdomain:domain"); @@ -4632,6 +5023,7 @@ public void testTokenExchangeRequestedRolesSingleRole() { OAuth2Token subjectToken = Mockito.mock(OAuth2Token.class); String requestDomainName = "testdomain"; + Mockito.when(subjectToken.getAudience()).thenReturn(requestDomainName); Mockito.when(subjectToken.getClaim(AccessToken.CLAIM_SCOPE_STD)).thenReturn("admin writer"); Mockito.when(accessTokenRequest.getScope()).thenReturn("testdomain:role.admin"); @@ -4647,6 +5039,7 @@ public void testTokenExchangeRequestedRolesEmptySubjectTokenScope() { OAuth2Token subjectToken = Mockito.mock(OAuth2Token.class); String requestDomainName = "testdomain"; + Mockito.when(subjectToken.getAudience()).thenReturn(requestDomainName); Mockito.when(subjectToken.getClaim(AccessToken.CLAIM_SCOPE_STD)).thenReturn(""); Mockito.when(accessTokenRequest.getScope()).thenReturn("testdomain:role.admin"); @@ -4661,6 +5054,7 @@ public void testTokenExchangeRequestedRolesScopeClaimAsString() { // Test that toString() is called on the scope claim Object scopeClaim = "testdomain:role.admin testdomain:role.writer"; + Mockito.when(subjectToken.getAudience()).thenReturn(requestDomainName); Mockito.when(subjectToken.getClaim(AccessToken.CLAIM_SCOPE_STD)).thenReturn(scopeClaim); Mockito.when(accessTokenRequest.getScope()).thenReturn(""); @@ -4668,8 +5062,8 @@ public void testTokenExchangeRequestedRolesScopeClaimAsString() { assertNotNull(result); assertEquals(result.length, 2); Set resultSet = new HashSet<>(Arrays.asList(result)); - assertTrue(resultSet.contains("testdomain:role.admin")); - assertTrue(resultSet.contains("testdomain:role.writer")); + assertTrue(resultSet.contains("admin")); + assertTrue(resultSet.contains("writer")); } private String createAccessToken(PrivateKey privateKey, final String keyId, final String subject, @@ -4705,7 +5099,6 @@ private String createAccessToken(PrivateKey privateKey, final String keyId, fina if (spiffe != null) { accessToken.setCustomClaim("spiffe", spiffe); } - ServerPrivateKey serverPrivateKey = new ServerPrivateKey(privateKey, keyId); return accessToken.getSignedToken(serverPrivateKey.getKey(), serverPrivateKey.getId(), @@ -4801,6 +5194,71 @@ private void addTokenTargetExchangePolicy(String targetDomainName, String source .setKeyId("0"), false); } + @Test + public void testProcessAccessTokenDelegationRequestTargetExchangeRequiresSourceDomain() + 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); + + System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_private.pem"); + + SignedDomain sourceDomain = createSignedDomain("sourcedomain", "weather", "storage", true); + store.processSignedDomain(sourceDomain, false); + + SignedDomain targetDomain = createSignedDomain("targetdomain", "weather", "storage", true); + store.processSignedDomain(targetDomain, false); + + addTokenSourceExchangePolicy("sourcedomain", "targetdomain", "user_domain.proxy-user1"); + + // This grants targetdomain:targetdomain:role.writers, but the source token audience + // is sourcedomain, so it must not authorize sourcedomain -> targetdomain exchange. + addTokenTargetExchangePolicy("targetdomain", "targetdomain", "user_domain.proxy-user1", "writers"); + + final File ecPrivateKey = new File("./src/test/resources/unit_test_zts_private_ec.pem"); + PrivateKey privateKey = Crypto.loadPrivateKey(ecPrivateKey); + KeyStore keyStore = getServerPublicKeyProvider(privateKey); + + long expiryTime = System.currentTimeMillis() / 1000 + 3600; + List subjectRoles = List.of("targetdomain:role.writers"); + String subjectTokenStr = createAccessToken(privateKey, "0", "user_domain.user", + "sourcedomain", subjectRoles, "user_domain.proxy-user1", null, expiryTime); + + String actorTokenStr = createActorToken(privateKey, "0", "user_domain.proxy-user1", + "targetdomain", expiryTime); + + 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); + TokenConfigOptions tokenConfigOptions = createTokenConfigOptions(ztsImpl); + tokenConfigOptions.setOauth2Issuers(Set.of("https://athenz.io:4443/zts/v1")); + tokenConfigOptions.setPublicKeyProvider(keyStore); + AccessTokenRequest accessTokenRequest = new AccessTokenRequest( + "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" + + "&requested_token_type=urn:ietf:params:oauth:token-type:access_token" + + "&subject_token=" + subjectTokenStr + + "&subject_token_type=urn:ietf:params:oauth:token-type:access_token" + + "&actor_token=" + actorTokenStr + + "&actor_token_type=urn:ietf:params:oauth:token-type:access_token" + + "&audience=targetdomain" + + "&scope=targetdomain:role.writers", + tokenConfigOptions); + + try { + ztsImpl.processAccessTokenDelegationRequest(context, principal, + accessTokenRequest.getActorTokenObj(), accessTokenRequest, "user_domain", "postAccessTokenRequest"); + fail("Expected ResourceException for not authorized"); + } catch (ResourceException ex) { + assertEquals(ex.getCode(), ResourceException.FORBIDDEN); + assertTrue(ex.getMessage().contains("Principal not authorized for token exchange")); + } + + cloudStore.close(); + } + @Test public void testProcessAccessTokenDelegationRequestSuccess() throws JOSEException { System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_at_private.pem"); @@ -4829,9 +5287,9 @@ public void testProcessAccessTokenDelegationRequestSuccess() throws JOSEExceptio PrivateKey privateKey = Crypto.loadPrivateKey(ecPrivateKey); KeyStore keyStore = getServerPublicKeyProvider(privateKey); - // Create subject token (AccessToken) with roles in source domain + // Create subject token (AccessToken) with target-domain roles and source-domain audience long expiryTime = System.currentTimeMillis() / 1000 + 3600; - List subjectRoles = List.of("writers"); + List subjectRoles = List.of("targetdomain:role.writers"); String subjectTokenStr = createAccessToken(privateKey, "0", "user_domain.user", "sourcedomain", subjectRoles, "user_domain.proxy-user1", null, expiryTime); @@ -4925,9 +5383,9 @@ public void testProcessAccessTokenExchangeDelegationRequestSuccess() throws JOSE PrivateKey privateKey = Crypto.loadPrivateKey(ecPrivateKey); KeyStore keyStore = getServerPublicKeyProvider(privateKey); - // Create subject token (AccessToken) with roles in source domain + // Create subject token with the explicit target-domain role. long expiryTime = System.currentTimeMillis() / 1000 + 3600; - List subjectRoles = List.of("writers"); + List subjectRoles = List.of("targetdomain:role.writers"); String subjectTokenStr = createAccessToken(privateKey, "0", "user_domain.user", "sourcedomain", subjectRoles, "user_domain.proxy-user1", null, expiryTime); @@ -5022,9 +5480,9 @@ public void testProcessAccessTokenExchangeDelegationRequestSuccessWithSpiffeClai PrivateKey privateKey = Crypto.loadPrivateKey(ecPrivateKey); KeyStore keyStore = getServerPublicKeyProvider(privateKey); - // Create subject token (AccessToken) with roles in source domain + // Create subject token with the explicit target-domain role. long expiryTime = System.currentTimeMillis() / 1000 + 3600; - List subjectRoles = List.of("writers"); + List subjectRoles = List.of("targetdomain:role.writers"); final String spiffeId = "spiffe://user_domain/sa/user"; String subjectTokenStr = createAccessToken(privateKey, "0", "user_domain.user", "sourcedomain", subjectRoles, "user_domain.proxy-user1", null, expiryTime, spiffeId); @@ -5110,7 +5568,7 @@ public void testProcessAccessTokenExchangeDelegationRequestSpiffeClaimMismatch() KeyStore keyStore = getServerPublicKeyProvider(privateKey); long expiryTime = System.currentTimeMillis() / 1000 + 3600; - List subjectRoles = List.of("writers"); + List subjectRoles = List.of("targetdomain:role.writers"); String subjectTokenStr = createAccessToken(privateKey, "0", "user_domain.user", "sourcedomain", subjectRoles, "user_domain.proxy-user1", null, expiryTime, "spiffe://sourcedomain/sa/weather"); @@ -5174,9 +5632,9 @@ public void testProcessAccessTokenExchangeDelegationRequestSuccessMultipleActors PrivateKey privateKey = Crypto.loadPrivateKey(ecPrivateKey); KeyStore keyStore = getServerPublicKeyProvider(privateKey); - // Create subject token (AccessToken) with roles in source domain + // Create subject token with the explicit target-domain role. long expiryTime = System.currentTimeMillis() / 1000 + 3600; - List subjectRoles = List.of("writers"); + List subjectRoles = List.of("targetdomain:role.writers"); String subjectTokenStr = createAccessToken(privateKey, "0", "user_domain.user", "sourcedomain", subjectRoles, "user_domain.proxy-user1", "athenz.actor", expiryTime); @@ -5274,9 +5732,9 @@ public void testProcessAccessTokenExchangeDelegationRequestWithMayActSuccess() t PrivateKey privateKey = Crypto.loadPrivateKey(ecPrivateKey); KeyStore keyStore = getServerPublicKeyProvider(privateKey); - // Create subject token (AccessToken) with roles in source domain + // Create subject token with the explicit target-domain role. long expiryTime = System.currentTimeMillis() / 1000 + 3600; - List subjectRoles = List.of("writers"); + List subjectRoles = List.of("targetdomain:role.writers"); String subjectTokenStr = createAccessToken(privateKey, "0", "user_domain.user", "sourcedomain", subjectRoles, "user_domain.proxy-user1", null, expiryTime); @@ -5376,7 +5834,7 @@ public void testProcessAccessTokenDelegationRequestPrincipalMismatch() { KeyStore keyStore = getServerPublicKeyProvider(privateKey); long expiryTime = System.currentTimeMillis() / 1000 + 3600; - List subjectRoles = List.of("writers"); + List subjectRoles = List.of("targetdomain:role.writers"); String subjectTokenStr = createAccessToken(privateKey, "0", "user_domain.user", "sourcedomain", subjectRoles, "user_domain.different-user", null, expiryTime); @@ -5432,7 +5890,7 @@ public void testProcessAccessTokenDelegationRequestTargetDomainNotFound() { KeyStore keyStore = getServerPublicKeyProvider(privateKey); long expiryTime = System.currentTimeMillis() / 1000 + 3600; - List subjectRoles = List.of("writers"); + List subjectRoles = List.of("targetdomain:role.writers"); String subjectTokenStr = createAccessToken(privateKey, "0", "user_domain.user", "sourcedomain", subjectRoles, "user_domain.proxy-user1", null, expiryTime); @@ -5488,7 +5946,7 @@ public void testProcessAccessTokenDelegationRequestSourceDomainNotFound() { long expiryTime = System.currentTimeMillis() / 1000 + 3600; // Subject token has audience pointing to non-existent source domain - List subjectRoles = List.of("writers"); + List subjectRoles = List.of("targetdomain:role.writers"); String subjectTokenStr = createAccessToken(privateKey, "0", "user_domain.user", "nonexistentdomain", subjectRoles, "user_domain.proxy-user1", null, expiryTime); @@ -5546,7 +6004,7 @@ public void testProcessAccessTokenDelegationRequestInvalidScope() { KeyStore keyStore = getServerPublicKeyProvider(privateKey); long expiryTime = System.currentTimeMillis() / 1000 + 3600; - // Subject token only has "writers" role + // The simple role belongs only to the source-domain audience. List subjectRoles = List.of("writers"); String subjectTokenStr = createAccessToken(privateKey, "0", "user_domain.user", "sourcedomain", subjectRoles, "user_domain.proxy-user1", null, expiryTime); @@ -5562,7 +6020,7 @@ public void testProcessAccessTokenDelegationRequestInvalidScope() { TokenConfigOptions tokenConfigOptions = createTokenConfigOptions(ztsImpl); tokenConfigOptions.setOauth2Issuers(Set.of("https://athenz.io:4443/zts/v1")); tokenConfigOptions.setPublicKeyProvider(keyStore); - // Request a role that's not in the subject token + // The subject is a target-domain writer, but that role was not delegated by the subject token. AccessTokenRequest accessTokenRequest = new AccessTokenRequest( "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" + "&requested_token_type=urn:ietf:params:oauth:token-type:access_token" @@ -5571,7 +6029,7 @@ public void testProcessAccessTokenDelegationRequestInvalidScope() { + "&actor_token=" + actorTokenStr + "&actor_token_type=urn:ietf:params:oauth:token-type:access_token" + "&audience=targetdomain" - + "&scope=targetdomain:role.readers", + + "&scope=targetdomain:role.writers", tokenConfigOptions); try { @@ -5626,7 +6084,7 @@ public void testProcessAccessTokenDelegationRequestNoAccessibleRoles() { KeyStore keyStore = getServerPublicKeyProvider(privateKey); long expiryTime = System.currentTimeMillis() / 1000 + 3600; - List subjectRoles = List.of("writers"); + List subjectRoles = List.of("targetdomain:role.writers"); String subjectTokenStr = createAccessToken(privateKey, "0", "user_domain.user", "sourcedomain", subjectRoles, "user_domain.proxy-user1", null, expiryTime); @@ -5689,7 +6147,7 @@ public void testProcessAccessTokenDelegationRequestNotAuthorizedForExchange() { KeyStore keyStore = getServerPublicKeyProvider(privateKey); long expiryTime = System.currentTimeMillis() / 1000 + 3600; - List subjectRoles = List.of("writers"); + List subjectRoles = List.of("targetdomain:role.writers"); String subjectTokenStr = createAccessToken(privateKey, "0", "user_domain.user", "sourcedomain", subjectRoles, "user_domain.proxy-user1", null, expiryTime); @@ -5753,8 +6211,9 @@ public void testProcessAccessTokenDelegationRequestMultipleRoles() throws JOSEEx KeyStore keyStore = getServerPublicKeyProvider(privateKey); long expiryTime = System.currentTimeMillis() / 1000 + 3600; - // Subject token has both roles - List subjectRoles = Arrays.asList("writers", "readers"); + // Subject token explicitly delegates both target-domain roles. + List subjectRoles = Arrays.asList( + "targetdomain:role.writers", "targetdomain:role.readers"); String subjectTokenStr = createAccessToken(privateKey, "0", "user_domain.user1", "sourcedomain", subjectRoles, "user_domain.proxy-user1", null, expiryTime); @@ -5840,7 +6299,7 @@ public void testProcessAccessTokenDelegationRequestWithExpiryTime() { KeyStore keyStore = getServerPublicKeyProvider(privateKey); long expiryTime = System.currentTimeMillis() / 1000 + 3600; - List subjectRoles = List.of("writers"); + List subjectRoles = List.of("targetdomain:role.writers"); String subjectTokenStr = createAccessToken(privateKey, "0", "user_domain.user", "sourcedomain", subjectRoles, "user_domain.proxy-user1", null, expiryTime); @@ -5902,7 +6361,7 @@ public void testProcessAccessTokenDelegationRequestWithOpenIDIssuer() throws JOS KeyStore keyStore = getServerPublicKeyProvider(privateKey); long expiryTime = System.currentTimeMillis() / 1000 + 3600; - List subjectRoles = List.of("writers"); + List subjectRoles = List.of("targetdomain:role.writers"); String subjectTokenStr = createAccessToken(privateKey, "0", "user_domain.user", "sourcedomain", subjectRoles, "user_domain.proxy-user1", null, expiryTime); @@ -5999,7 +6458,7 @@ public void testProcessAccessTokenDelegationRequestDefaultScope() { KeyStore keyStore = getServerPublicKeyProvider(privateKey); long expiryTime = System.currentTimeMillis() / 1000 + 3600; - List subjectRoles = List.of("writers"); + List subjectRoles = List.of("targetdomain:role.writers"); String subjectTokenStr = createAccessToken(privateKey, "0", "user_domain.user", "sourcedomain", subjectRoles, "user_domain.proxy-user1", null, expiryTime); @@ -6111,7 +6570,6 @@ private String createSubjectToken(PrivateKey privateKey, final String keyId, fin accessToken.setClientId(subject); accessToken.setIssuer("https://athenz.io:4443/zts/v1"); accessToken.setScope(roles != null ? roles : new ArrayList<>()); - ServerPrivateKey serverPrivateKey = new ServerPrivateKey(privateKey, keyId); return accessToken.getSignedToken(serverPrivateKey.getKey(), serverPrivateKey.getId(), @@ -6150,9 +6608,9 @@ public void testProcessAccessTokenImpersonationRequestSuccess() throws JOSEExcep PrivateKey privateKey = Crypto.loadPrivateKey(ecPrivateKey); KeyStore keyStore = getServerPublicKeyProvider(privateKey); - // Create subject token (AccessToken) with roles in source domain + // Create subject token (AccessToken) with target-domain roles and source-domain audience long expiryTime = System.currentTimeMillis() / 1000 + 3600; - List subjectRoles = List.of("writers"); + List subjectRoles = List.of("targetdomain:role.writers"); String subjectTokenStr = createSubjectToken(privateKey, "0", "user_domain.user", "sourcedomain", subjectRoles, expiryTime); @@ -6234,7 +6692,7 @@ public void testProcessAccessTokenImpersonationRequestSuccessDefaultScope() { KeyStore keyStore = getServerPublicKeyProvider(privateKey); long expiryTime = System.currentTimeMillis() / 1000 + 3600; - List subjectRoles = List.of("writers"); + List subjectRoles = List.of("targetdomain:role.writers"); String subjectTokenStr = createSubjectToken(privateKey, "0", "user_domain.user", "sourcedomain", subjectRoles, expiryTime); @@ -6288,7 +6746,8 @@ public void testProcessAccessTokenImpersonationRequestSuccessMultipleRoles() { KeyStore keyStore = getServerPublicKeyProvider(privateKey); long expiryTime = System.currentTimeMillis() / 1000 + 3600; - List subjectRoles = Arrays.asList("writers", "readers"); + List subjectRoles = Arrays.asList( + "targetdomain:role.writers", "targetdomain:role.readers"); String subjectTokenStr = createSubjectToken(privateKey, "0", "user_domain.user1", "sourcedomain", subjectRoles, expiryTime); @@ -6341,7 +6800,7 @@ public void testProcessAccessTokenImpersonationRequestSuccessWithOpenIDIssuer() KeyStore keyStore = getServerPublicKeyProvider(privateKey); long expiryTime = System.currentTimeMillis() / 1000 + 3600; - List subjectRoles = List.of("writers"); + List subjectRoles = List.of("targetdomain:role.writers"); String subjectTokenStr = createSubjectToken(privateKey, "0", "user_domain.user", "sourcedomain", subjectRoles, expiryTime); @@ -6401,7 +6860,7 @@ public void testProcessAccessTokenImpersonationRequestTargetDomainNotFound() { KeyStore keyStore = getServerPublicKeyProvider(privateKey); long expiryTime = System.currentTimeMillis() / 1000 + 3600; - List subjectRoles = List.of("writers"); + List subjectRoles = List.of("targetdomain:role.writers"); String subjectTokenStr = createSubjectToken(privateKey, "0", "user_domain.user", "sourcedomain", subjectRoles, expiryTime); @@ -6449,7 +6908,7 @@ public void testProcessAccessTokenImpersonationRequestSourceDomainNotFound() { KeyStore keyStore = getServerPublicKeyProvider(privateKey); long expiryTime = System.currentTimeMillis() / 1000 + 3600; - List subjectRoles = List.of("writers"); + List subjectRoles = List.of("targetdomain:role.writers"); // Subject token has audience set to non-existent source domain String subjectTokenStr = createSubjectToken(privateKey, "0", "user_domain.user", "nonexistentsource", subjectRoles, expiryTime); @@ -6501,7 +6960,7 @@ public void testProcessAccessTokenImpersonationRequestInvalidScope() { KeyStore keyStore = getServerPublicKeyProvider(privateKey); long expiryTime = System.currentTimeMillis() / 1000 + 3600; - // Subject token only has "writers" role + // The simple role belongs only to the source-domain audience. List subjectRoles = List.of("writers"); String subjectTokenStr = createSubjectToken(privateKey, "0", "user_domain.user", "sourcedomain", subjectRoles, expiryTime); @@ -6512,14 +6971,14 @@ public void testProcessAccessTokenImpersonationRequestInvalidScope() { TokenConfigOptions tokenConfigOptions = createTokenConfigOptions(ztsImpl); tokenConfigOptions.setOauth2Issuers(Set.of("https://athenz.io:4443/zts/v1")); tokenConfigOptions.setPublicKeyProvider(keyStore); - // Requesting "readers" role which is not in subject token + // The subject is a target-domain writer, but that role was not delegated by the subject token. AccessTokenRequest accessTokenRequest = new AccessTokenRequest( "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" + "&requested_token_type=urn:ietf:params:oauth:token-type:access_token" + "&subject_token=" + subjectTokenStr + "&subject_token_type=urn:ietf:params:oauth:token-type:access_token" + "&audience=targetdomain" - + "&scope=targetdomain:role.readers", + + "&scope=targetdomain:role.writers", tokenConfigOptions); try { @@ -6557,8 +7016,8 @@ public void testProcessAccessTokenImpersonationRequestSubjectNoAccess() { KeyStore keyStore = getServerPublicKeyProvider(privateKey); long expiryTime = System.currentTimeMillis() / 1000 + 3600; - // Subject token has "writers" but subject principal doesn't have access in target domain - List subjectRoles = List.of("writers"); + // Subject token delegates the target role, but the subject principal does not have access to it. + List subjectRoles = List.of("targetdomain:role.writers"); String subjectTokenStr = createSubjectToken(privateKey, "0", "user_domain.user5", "sourcedomain", subjectRoles, expiryTime); @@ -6612,7 +7071,7 @@ public void testProcessAccessTokenImpersonationRequestNotAuthorizedForSourceExch KeyStore keyStore = getServerPublicKeyProvider(privateKey); long expiryTime = System.currentTimeMillis() / 1000 + 3600; - List subjectRoles = List.of("writers"); + List subjectRoles = List.of("targetdomain:role.writers"); String subjectTokenStr = createSubjectToken(privateKey, "0", "user_domain.user", "sourcedomain", subjectRoles, expiryTime); @@ -6666,7 +7125,7 @@ public void testProcessAccessTokenImpersonationRequestSpiffeClaimMismatch() { KeyStore keyStore = getServerPublicKeyProvider(privateKey); long expiryTime = System.currentTimeMillis() / 1000 + 3600; - List subjectRoles = List.of("writers"); + List subjectRoles = List.of("targetdomain:role.writers"); String subjectTokenStr = createAccessToken(privateKey, "0", "user_domain.user", "sourcedomain", subjectRoles, null, null, expiryTime, "spiffe://sourcedomain/sa/weather"); @@ -6721,7 +7180,7 @@ public void testProcessAccessTokenImpersonationRequestSuccessWithSpiffeClaim() t KeyStore keyStore = getServerPublicKeyProvider(privateKey); long expiryTime = System.currentTimeMillis() / 1000 + 3600; - List subjectRoles = List.of("writers"); + List subjectRoles = List.of("targetdomain:role.writers"); final String spiffeId = "spiffe://user_domain/sa/user"; String subjectTokenStr = createAccessToken(privateKey, "0", "user_domain.user", "sourcedomain", subjectRoles, null, null, expiryTime, spiffeId); @@ -6793,7 +7252,7 @@ public void testProcessAccessTokenImpersonationRequestNotAuthorizedForTargetExch KeyStore keyStore = getServerPublicKeyProvider(privateKey); long expiryTime = System.currentTimeMillis() / 1000 + 3600; - List subjectRoles = List.of("writers"); + List subjectRoles = List.of("targetdomain:role.writers"); String subjectTokenStr = createSubjectToken(privateKey, "0", "user_domain.user", "sourcedomain", subjectRoles, expiryTime); @@ -6848,7 +7307,8 @@ public void testProcessAccessTokenImpersonationRequestPartialRoleAccess() { KeyStore keyStore = getServerPublicKeyProvider(privateKey); long expiryTime = System.currentTimeMillis() / 1000 + 3600; - List subjectRoles = Arrays.asList("writers", "readers"); + List subjectRoles = Arrays.asList( + "targetdomain:role.writers", "targetdomain:role.readers"); String subjectTokenStr = createSubjectToken(privateKey, "0", "user_domain.user", "sourcedomain", subjectRoles, expiryTime); @@ -6903,7 +7363,7 @@ public void testProcessAccessTokenImpersonationRequestSuccessWithMTLS() throws E X509Certificate cert = Crypto.loadX509Certificate(certPem); long expiryTime = System.currentTimeMillis() / 1000 + 3600; - List subjectRoles = List.of("writers"); + List subjectRoles = List.of("targetdomain:role.writers"); String subjectTokenStr = createSubjectToken(privateKey, "0", "user_domain.user", "sourcedomain", subjectRoles, expiryTime); @@ -6978,7 +7438,7 @@ public void testProcessAccessTokenImpersonationRequestSuccessWithProxyPrincipals X509Certificate cert = Crypto.loadX509Certificate(certPem); long expiryTime = System.currentTimeMillis() / 1000 + 3600; - List subjectRoles = List.of("writers"); + List subjectRoles = List.of("targetdomain:role.writers"); String subjectTokenStr = createSubjectToken(privateKey, "0", "user_domain.user", "sourcedomain", subjectRoles, expiryTime); @@ -7056,8 +7516,8 @@ public void testProcessAccessTokenImpersonationRequestEmptyRoles() { KeyStore keyStore = getServerPublicKeyProvider(privateKey); long expiryTime = System.currentTimeMillis() / 1000 + 3600; - // Subject token has "writers" but subject principal doesn't have access in target domain - List subjectRoles = List.of("writers"); + // Subject token delegates the target role, but the subject principal does not have access to it. + List subjectRoles = List.of("targetdomain:role.writers"); String subjectTokenStr = createSubjectToken(privateKey, "0", "user_domain.user5", "sourcedomain", subjectRoles, expiryTime); @@ -7112,7 +7572,8 @@ public void testProcessAccessTokenExchangeImpersonationRequestSuccess() throws J KeyStore keyStore = getServerPublicKeyProvider(privateKey); long expiryTime = System.currentTimeMillis() / 1000 + 3600; - List subjectRoles = Arrays.asList("writers", "readers"); + List subjectRoles = Arrays.asList( + "targetdomain:role.writers", "targetdomain:role.readers"); String subjectTokenStr = createSubjectToken(privateKey, "0", "user_domain.user1", "sourcedomain", subjectRoles, expiryTime); @@ -7194,7 +7655,8 @@ public void testProcessAccessTokenExchangeAuthorizedService() throws JOSEExcepti KeyStore keyStore = getServerPublicKeyProvider(privateKey); long expiryTime = System.currentTimeMillis() / 1000 + 3600; - List subjectRoles = Arrays.asList("writers", "readers"); + List subjectRoles = Arrays.asList( + "targetdomain:role.writers", "targetdomain:role.readers"); String subjectTokenStr = createSubjectToken(privateKey, "0", "user_domain.user1", "sourcedomain", subjectRoles, expiryTime); diff --git a/servers/zts/src/test/java/com/yahoo/athenz/zts/ZTSTest.java b/servers/zts/src/test/java/com/yahoo/athenz/zts/ZTSTest.java index c18f819877e..7615e96cd71 100644 --- a/servers/zts/src/test/java/com/yahoo/athenz/zts/ZTSTest.java +++ b/servers/zts/src/test/java/com/yahoo/athenz/zts/ZTSTest.java @@ -71,4 +71,10 @@ public void testZTSBinder() { ZTSBinder binder = ZTSBinder.getInstance(); binder.configure(); } + + @Test + public void testZTSImplFactory() { + assertNotNull(new ZTSImplFactory()); + assertNotNull(ZTSImplFactory.getZtsInstance()); + } } diff --git a/servers/zts/src/test/java/com/yahoo/athenz/zts/token/AccessTokenScopeTest.java b/servers/zts/src/test/java/com/yahoo/athenz/zts/token/AccessTokenScopeTest.java index bbc42560145..5136266c0b7 100644 --- a/servers/zts/src/test/java/com/yahoo/athenz/zts/token/AccessTokenScopeTest.java +++ b/servers/zts/src/test/java/com/yahoo/athenz/zts/token/AccessTokenScopeTest.java @@ -27,6 +27,8 @@ public class AccessTokenScopeTest { @BeforeMethod public void setup() { AccessTokenScope.setSupportOpenIdScope(true); + AccessTokenScope.setSupportRolesWithoutDomain(false); + AccessTokenScope.setMaxDomains(20); } @Test @@ -83,6 +85,19 @@ public void testAccessTokenScope() { assertFalse(req6.sendScopeResponse()); assertFalse(req6.isOpenIdScope()); + AccessTokenScope req6a = new AccessTokenScope("sports:role.role1 weather:role.role2", null); + assertNotNull(req6a); + assertNull(req6a.getDomainName()); + assertEquals(req6a.getDomainNames().size(), 2); + assertNotNull(req6a.getRoleNames("sports")); + assertEquals(req6a.getRoleNames("sports").length, 1); + assertEquals(req6a.getRoleNames("sports")[0], "role1"); + assertNotNull(req6a.getRoleNames("weather")); + assertEquals(req6a.getRoleNames("weather").length, 1); + assertEquals(req6a.getRoleNames("weather")[0], "role2"); + assertFalse(req6a.sendScopeResponse()); + assertFalse(req6a.isOpenIdScope()); + AccessTokenScope.setSupportRolesWithoutDomain(true); AccessTokenScope req7 = new AccessTokenScope("role1 role2", "sports"); assertNotNull(req7); @@ -200,25 +215,55 @@ public void testAccessTokenScopeMultipleDomains() { AccessTokenScope req1 = new AccessTokenScope("sports:domain sports:domain", null); assertNotNull(req1); - try { - new AccessTokenScope("sports:domain weather:domain", null); - fail(); - } catch (ResourceException ex) { - assertEquals(ex.getCode(), 400); - } + AccessTokenScope req2 = new AccessTokenScope("sports:domain weather:domain", null); + assertNotNull(req2); + assertEquals(req2.getDomainNames().size(), 2); + assertTrue(req2.sendScopeResponse()); + + AccessTokenScope req3 = new AccessTokenScope("sports:domain weather:role.role1", null); + assertNotNull(req3); + assertEquals(req3.getDomainNames().size(), 2); + assertTrue(req3.sendScopeResponse()); + assertNull(req3.getRoleNames("sports")); + assertEquals(req3.getRoleNames("weather"), new String[] { "role1" }); + + AccessTokenScope req4 = new AccessTokenScope("weather:role.role2 sports:domain weather:role.role1", null); + assertNotNull(req4); + assertEquals(req4.getDomainNames().size(), 2); + assertTrue(req4.sendScopeResponse()); + assertNull(req4.getRoleNames("sports")); + assertEquals(req4.getRoleNames("weather").length, 2); + assertTrue(ZTSTestUtils.validArrayMember(req4.getRoleNames("weather"), "role1")); + assertTrue(ZTSTestUtils.validArrayMember(req4.getRoleNames("weather"), "role2")); + } + + @Test + public void testAccessTokenScopeMaxDomains() { + + AccessTokenScope.setMaxDomains(2); + AccessTokenScope req1 = new AccessTokenScope("sports:domain weather:domain sports:role.role1", null); + assertNotNull(req1); + assertEquals(req1.getDomainNames().size(), 2); try { - new AccessTokenScope("sports:domain weather:role.role1", null); + new AccessTokenScope("sports:domain weather:domain news:role.role1", null); fail(); } catch (ResourceException ex) { assertEquals(ex.getCode(), 400); + assertTrue(ex.getMessage().contains("Domain limit: 2 has been reached")); } + } + + @Test + public void testAccessTokenScopeMaxDomainsOneLegacyErrorMessage() { + AccessTokenScope.setMaxDomains(1); try { - new AccessTokenScope("weather:role.role2 sports:domain weather:role.role1", null); + new AccessTokenScope("sports:role.role1 weather:role.role2", null); fail(); } catch (ResourceException ex) { assertEquals(ex.getCode(), 400); + assertTrue(ex.getMessage().contains("Multiple domains in scope")); } } } diff --git a/servers/zts/src/test/java/com/yahoo/athenz/zts/token/IdTokenScopeTest.java b/servers/zts/src/test/java/com/yahoo/athenz/zts/token/IdTokenScopeTest.java index 4923d41bf21..85921f397d0 100644 --- a/servers/zts/src/test/java/com/yahoo/athenz/zts/token/IdTokenScopeTest.java +++ b/servers/zts/src/test/java/com/yahoo/athenz/zts/token/IdTokenScopeTest.java @@ -110,6 +110,22 @@ public void testIdTokenScope() { assertTrue(req8.isGroupsScope()); assertFalse(req8.isRolesScope()); assertFalse(req7.sendScopeResponse()); + + IdTokenScope req9 = new IdTokenScope("openid groups sports:domain"); + assertNotNull(req9); + assertEquals(req9.getDomainName(), "sports"); + assertNull(req9.getGroupNames("sports")); + assertTrue(req9.sendScopeResponse()); + assertTrue(req9.isGroupsScope()); + assertFalse(req9.isRolesScope()); + + IdTokenScope req10 = new IdTokenScope("openid roles sports:domain"); + assertNotNull(req10); + assertEquals(req10.getDomainName(), "sports"); + assertNull(req10.getRoleNames("sports")); + assertTrue(req10.sendScopeResponse()); + assertFalse(req10.isGroupsScope()); + assertTrue(req10.isRolesScope()); } @Test