From 841ee2869ba0030cbe32fd4e6b56681b903b2993 Mon Sep 17 00:00:00 2001 From: Jeongwoo Kim - jekim Date: Sat, 4 Jul 2026 17:05:01 +0900 Subject: [PATCH 01/30] feat Signed-off-by: Jeongwoo Kim - jekim --- .../java/com/yahoo/athenz/zts/ZTSImpl.java | 289 +++++++++++++----- .../athenz/zts/token/AccessTokenScope.java | 8 +- .../athenz/zts/token/OAuthTokenScope.java | 4 +- .../athenz/zts/ZTSImplAccessTokenTest.java | 117 ++++++- .../zts/token/AccessTokenScopeTest.java | 43 +-- 5 files changed, 354 insertions(+), 107 deletions(-) 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..41d9b31d48e 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 @@ -2873,9 +2873,7 @@ AccessTokenResponse processAccessTokenImpersonationRequest(ResourceContext ctx, final String principalName = principal.getFullName(); for (String role : roles) { - if (!authorizer.access(ZTSConsts.ZTS_ACTION_TOKEN_TARGET_EXCHANGE, - requestDomainName + ":" + ResourceUtils.roleResourceName(sourceDomainName, role), - principal, null)) { + if (!isPrincipalAuthorizedForTokenTargetExchange(requestDomainName, sourceDomainName, role, principal)) { LOGGER.error("processAccessTokenImpersonationRequest: access check failure for {} - {}:{}:role.{}", principalName, requestDomainName, sourceDomainName, role); throw forbiddenError("Principal not authorized for token exchange for the requested role", @@ -2927,11 +2925,33 @@ AccessTokenResponse processAccessTokenImpersonationRequest(ResourceContext ctx, .setScope(generateScopeResponse(roles, requestDomainName, false)); } + boolean isPrincipalAuthorizedForTokenTargetExchange(final String targetDomainName, + final String sourceDomainName, final String role, Principal principal) { + + // Prefer the target role domain for new tokens that can carry fully qualified scopes + // even when their audience is a different source domain. Keep the legacy source-domain + // resource for existing deployments that still authorize exchanges that way. + + final String targetRoleResource = targetDomainName + ":" + + ResourceUtils.roleResourceName(targetDomainName, role); + if (authorizer.access(ZTSConsts.ZTS_ACTION_TOKEN_TARGET_EXCHANGE, targetRoleResource, principal, null)) { + return true; + } + + if (targetDomainName.equals(sourceDomainName)) { + return false; + } + + final String sourceRoleResource = targetDomainName + ":" + + ResourceUtils.roleResourceName(sourceDomainName, role); + return authorizer.access(ZTSConsts.ZTS_ACTION_TOKEN_TARGET_EXCHANGE, sourceRoleResource, principal, null); + } + String[] tokenExchangeRequestedRoles(AccessTokenRequest accessTokenRequest, OAuth2Token subjectToken, 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 +2959,46 @@ String[] tokenExchangeRequestedRoles(AccessTokenRequest accessTokenRequest, OAut return null; } - String[] scopeRoles = scopeClaim.toString().split(" "); + final String sourceDomainName = subjectToken.getAudience(); + Set scopeRoles = new HashSet<>(Arrays.asList(scopeClaim.toString().split("\\s+"))); + if (scopeRoles.isEmpty() || scopeRoles.contains("")) { + LOGGER.error("subject token does not contain any scope roles"); + return null; + } 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. + // For backward compatibility, simple roles in the subject token belong + // to the subject token audience. Fully qualified roles belong to their + // explicitly named domain. 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 +3006,38 @@ String[] tokenExchangeRequestedRoles(AccessTokenRequest accessTokenRequest, OAut } } + boolean subjectTokenHasRequestedRoles(Set subjectScopes, final String sourceDomainName, + final String requestDomainName, String[] requestedRoles) { + + for (String requestedRole : requestedRoles) { + if (subjectScopes.contains(requestDomainName + OAuthTokenScope.OBJECT_ROLE + requestedRole)) { + continue; + } + if ((StringUtil.isEmpty(sourceDomainName) || 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 (subjectScope.startsWith(roleScopePrefix)) { + requestedRoles.add(subjectScope.substring(roleScopePrefix.length())); + } else if (!subjectScope.contains(OAuthTokenScope.OBJECT_ROLE) && + (StringUtil.isEmpty(sourceDomainName) || 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) { @@ -3040,9 +3110,7 @@ AccessTokenResponse processAccessTokenDelegationRequest(ResourceContext ctx, Pri final String principalName = principal.getFullName(); for (String role : roles) { - if (!authorizer.access(ZTSConsts.ZTS_ACTION_TOKEN_TARGET_EXCHANGE, - requestDomainName + ":" + ResourceUtils.roleResourceName(sourceDomainName, role), - principal, null)) { + if (!isPrincipalAuthorizedForTokenTargetExchange(requestDomainName, sourceDomainName, role, principal)) { LOGGER.error("processAccessTokenDelegationRequest: access check failure for {} - {}:{}:role.{}", principalName, requestDomainName, sourceDomainName, role); throw forbiddenError("Principal not authorized for token exchange for the requested role", @@ -3119,6 +3187,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) { @@ -3731,103 +3826,141 @@ AccessTokenResponse processAccessTokenStandardRequest(ResourceContext ctx, Princ // before using any of our values let's validate that they // match our schema - final String domainName = tokenScope.getDomainName(); - 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); - } + 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); } - // first retrieve our domain data object from the cache + final String domainName = StringUtil.isEmpty(accessTokenRequest.getAudience()) ? + tokenScope.getDomainName() : accessTokenRequest.getAudience().toLowerCase(); + setRequestDomain(ctx, domainName); + validate(domainName, TYPE_DOMAIN_NAME, principalDomain, caller); - 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 (tokenScope.isOpenIdScope() && tokenScope.getDomainName() == null) { + throw requestError("OpenID access token requests cannot include multiple domains", caller, + domainName, 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 = 0; + 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 = tokenTimeout == 0 ? domainTokenTimeout : 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 +3969,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 +4026,8 @@ 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)) { + 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..50142c475f1 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 @@ -34,11 +34,11 @@ public AccessTokenScope(final String scope, final String principalDomain) { // :role. // openid :service. - super(scope, 1, null, supportRolesWithoutDomain ? principalDomain : null); + super(scope, Integer.MAX_VALUE, 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 +49,10 @@ 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 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..bf5016cbf7c 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 @@ -192,7 +192,7 @@ public OAuthTokenScope(final String scope, int maxDomains, DynamicConfigCsv syst } public String getDomainName() { - return (maxDomains == 1 && !domainNames.isEmpty()) ? domainNames.stream().findFirst().get() : null; + return (domainNames.size() == 1) ? domainNames.stream().findFirst().get() : null; } public Set getDomainNames() { @@ -239,7 +239,7 @@ void addScopeDomain(final String scopeDomainName, final String scope, boolean en throw error("empty domain name", scope); } final String domainName = getDomainName(); - if (enforceMaxDomainCheck && domainName != null && !scopeDomainName.equals(domainName)) { + if (enforceMaxDomainCheck && maxDomains == 1 && domainName != null && !scopeDomainName.equals(domainName)) { throw error("Multiple domains in scope", scope); } if (!domainNames.contains(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..b37622ea108 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 @@ -411,6 +411,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 = 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 +552,62 @@ public void testPostAccessTokenRequest() throws JOSEException { } } + @Test + public void testPostAccessTokenRequestWithAudienceAndCrossDomainScope() 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); + // 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 testPostAccessTokenRequestEmptyProxyPrincipal() { @@ -4607,6 +4679,37 @@ 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 testTokenExchangeRequestedRolesSimpleRoleBelongsToSourceAudience() { + 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 testTokenExchangeRequestedRolesNoRequestedRoles() { AccessTokenRequest accessTokenRequest = Mockito.mock(AccessTokenRequest.class); @@ -4668,8 +4771,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, @@ -4762,7 +4865,7 @@ private void addTokenTargetExchangePolicy(String targetDomainName, String source exchangePolicy.setName(generatePolicyName(targetDomainName, "token_target_exchange_" + roleName)); Assertion assertion = new Assertion(); - assertion.setResource(targetDomainName + ":" + ResourceUtils.roleResourceName(sourceDomainName, roleName)); + assertion.setResource(targetDomainName + ":" + ResourceUtils.roleResourceName(targetDomainName, roleName)); assertion.setAction(ZTSConsts.ZTS_ACTION_TOKEN_TARGET_EXCHANGE); assertion.setEffect(com.yahoo.athenz.zms.AssertionEffect.ALLOW); @@ -4829,9 +4932,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); @@ -6150,9 +6253,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); 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..e938093cd1e 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 @@ -83,6 +83,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 +213,19 @@ 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()); - try { - new AccessTokenScope("sports:domain weather:role.role1", null); - fail(); - } catch (ResourceException ex) { - assertEquals(ex.getCode(), 400); - } + AccessTokenScope req3 = new AccessTokenScope("sports:domain weather:role.role1", null); + assertNotNull(req3); + assertEquals(req3.getDomainNames().size(), 2); + assertTrue(req3.sendScopeResponse()); - try { - new AccessTokenScope("weather:role.role2 sports:domain weather:role.role1", null); - fail(); - } catch (ResourceException ex) { - assertEquals(ex.getCode(), 400); - } + AccessTokenScope req4 = new AccessTokenScope("weather:role.role2 sports:domain weather:role.role1", null); + assertNotNull(req4); + assertEquals(req4.getDomainNames().size(), 2); + assertTrue(req4.sendScopeResponse()); } } From 7c8d12be4f96534289e687ec405017186e49c0b2 Mon Sep 17 00:00:00 2001 From: Jeongwoo Kim - jekim Date: Sat, 4 Jul 2026 17:20:39 +0900 Subject: [PATCH 02/30] feat Signed-off-by: Jeongwoo Kim - jekim --- .../java/com/yahoo/athenz/zts/ZTSImpl.java | 10 ++++-- .../athenz/zts/token/AccessTokenScope.java | 5 +++ .../athenz/zts/token/OAuthTokenScope.java | 11 +++---- .../athenz/zts/ZTSImplAccessTokenTest.java | 32 +++++++++++++++++++ 4 files changed, 49 insertions(+), 9 deletions(-) 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 41d9b31d48e..1ee4e5ed530 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 @@ -2960,11 +2960,12 @@ String[] tokenExchangeRequestedRoles(AccessTokenRequest accessTokenRequest, OAut } final String sourceDomainName = subjectToken.getAudience(); - Set scopeRoles = new HashSet<>(Arrays.asList(scopeClaim.toString().split("\\s+"))); - if (scopeRoles.isEmpty() || scopeRoles.contains("")) { + 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)) { String[] requestedRoles = getSubjectTokenRolesForDomain(scopeRoles, sourceDomainName, requestDomainName); @@ -3028,9 +3029,12 @@ String[] getSubjectTokenRolesForDomain(Set subjectScopes, final String s 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)) { requestedRoles.add(subjectScope.substring(roleScopePrefix.length())); - } else if (!subjectScope.contains(OAuthTokenScope.OBJECT_ROLE) && + } else if (!subjectScope.contains(":") && (StringUtil.isEmpty(sourceDomainName) || requestDomainName.equals(sourceDomainName))) { requestedRoles.add(subjectScope); } 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 50142c475f1..86b6fd4d6a1 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 @@ -55,6 +55,11 @@ public AccessTokenScope(final String scope, final String principalDomain) { } } + @Override + public String getDomainName() { + return (domainNames.size() == 1) ? domainNames.stream().findFirst().get() : null; + } + @Override public boolean isOpenIdScope() { return supportOpenIdScope && openIdScope; 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 bf5016cbf7c..befe19f5ba1 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 @@ -192,7 +192,7 @@ public OAuthTokenScope(final String scope, int maxDomains, DynamicConfigCsv syst } public String getDomainName() { - return (domainNames.size() == 1) ? domainNames.stream().findFirst().get() : null; + return (maxDomains == 1 && domainNames.size() == 1) ? domainNames.stream().findFirst().get() : null; } public Set getDomainNames() { @@ -238,12 +238,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 && maxDomains == 1 && 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 b37622ea108..2ff5bb82b25 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 @@ -4613,6 +4613,21 @@ 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.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); @@ -4696,6 +4711,23 @@ public void testTokenExchangeRequestedRolesCrossDomainScope() { assertEquals(result[0], "docs-getter"); } + @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 testTokenExchangeRequestedRolesSimpleRoleBelongsToSourceAudience() { AccessTokenRequest accessTokenRequest = Mockito.mock(AccessTokenRequest.class); From 01d8eaeeecf944021ec22a6968ba033837bd7c53 Mon Sep 17 00:00:00 2001 From: Jeongwoo Kim - jekim Date: Sat, 4 Jul 2026 17:29:17 +0900 Subject: [PATCH 03/30] fix Signed-off-by: Jeongwoo Kim - jekim --- .../java/com/yahoo/athenz/zts/ZTSImpl.java | 10 ++++---- .../athenz/zts/ZTSImplAccessTokenTest.java | 23 +++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) 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 1ee4e5ed530..ebf2f47dd23 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 @@ -2960,6 +2960,10 @@ String[] tokenExchangeRequestedRoles(AccessTokenRequest accessTokenRequest, OAut } 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"); @@ -3014,8 +3018,7 @@ boolean subjectTokenHasRequestedRoles(Set subjectScopes, final String so if (subjectScopes.contains(requestDomainName + OAuthTokenScope.OBJECT_ROLE + requestedRole)) { continue; } - if ((StringUtil.isEmpty(sourceDomainName) || requestDomainName.equals(sourceDomainName)) && - subjectScopes.contains(requestedRole)) { + if (requestDomainName.equals(sourceDomainName) && subjectScopes.contains(requestedRole)) { continue; } return false; @@ -3034,8 +3037,7 @@ String[] getSubjectTokenRolesForDomain(Set subjectScopes, final String s } if (subjectScope.startsWith(roleScopePrefix)) { requestedRoles.add(subjectScope.substring(roleScopePrefix.length())); - } else if (!subjectScope.contains(":") && - (StringUtil.isEmpty(sourceDomainName) || requestDomainName.equals(sourceDomainName))) { + } else if (!subjectScope.contains(":") && requestDomainName.equals(sourceDomainName)) { requestedRoles.add(subjectScope); } } 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 2ff5bb82b25..d022bb86fa9 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 @@ -4578,12 +4578,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(""); @@ -4602,6 +4615,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); @@ -4619,6 +4633,7 @@ public void testTokenExchangeRequestedRolesTrimSubjectTokenScope() { 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"); @@ -4635,6 +4650,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"); @@ -4654,6 +4670,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"); @@ -4672,6 +4689,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"); @@ -4686,6 +4704,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"); @@ -4748,6 +4767,7 @@ public void testTokenExchangeRequestedRolesNoRequestedRoles() { 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"); @@ -4767,6 +4787,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"); @@ -4782,6 +4803,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"); @@ -4796,6 +4818,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(""); From 1f14934ca8b7367b864b979ada6ca4f7073b410c Mon Sep 17 00:00:00 2001 From: Aaron Jeongwoo Kim <53258958+mlajkim@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:40:39 +0900 Subject: [PATCH 04/30] Update servers/zts/src/test/java/com/yahoo/athenz/zts/ZTSImplAccessTokenTest.java Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: Aaron Jeongwoo Kim <53258958+mlajkim@users.noreply.github.com> --- .../test/java/com/yahoo/athenz/zts/ZTSImplAccessTokenTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d022bb86fa9..4ead1a54752 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 @@ -415,7 +415,7 @@ private void addRoleMemberToSignedDomain(SignedDomain signedDomain, final String final String roleName, final String memberName) { DomainData domainData = signedDomain.getDomain(); - List roles = new ArrayList<>(domainData.getRoles()); + List roles = domainData.getRoles() == null ? new ArrayList<>() : new ArrayList<>(domainData.getRoles()); Role role = new Role(); role.setName(generateRoleName(domainName, roleName)); From b6a20a27eb53782dfbc449c40fa276332a108f70 Mon Sep 17 00:00:00 2001 From: Aaron Jeongwoo Kim <53258958+mlajkim@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:40:47 +0900 Subject: [PATCH 05/30] Update servers/zts/src/main/java/com/yahoo/athenz/zts/ZTSImpl.java Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: Aaron Jeongwoo Kim <53258958+mlajkim@users.noreply.github.com> --- servers/zts/src/main/java/com/yahoo/athenz/zts/ZTSImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ebf2f47dd23..e2bf966547c 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 @@ -3954,7 +3954,7 @@ AccessTokenResponse processAccessTokenStandardRequest(ResourceContext ctx, Princ rolesByDomain.put(scopeDomainName, roles); int domainTokenTimeout = determineTokenTimeout(data, roles, null, accessTokenRequest.getExpiryTime()); - tokenTimeout = tokenTimeout == 0 ? domainTokenTimeout : Math.min(tokenTimeout, domainTokenTimeout); + tokenTimeout = Math.min(tokenTimeout, domainTokenTimeout); if (tokenScope.sendScopeResponse() || requestedRoles != null && requestedRoles.length != roles.size()) { scopeAdjusted = true; } From a507e1cee4481dcc0a4aba4ee4db87c752f5c734 Mon Sep 17 00:00:00 2001 From: Aaron Jeongwoo Kim <53258958+mlajkim@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:40:53 +0900 Subject: [PATCH 06/30] Update servers/zts/src/main/java/com/yahoo/athenz/zts/ZTSImpl.java Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: Aaron Jeongwoo Kim <53258958+mlajkim@users.noreply.github.com> --- servers/zts/src/main/java/com/yahoo/athenz/zts/ZTSImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e2bf966547c..52a37dfd78c 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 @@ -3883,7 +3883,7 @@ AccessTokenResponse processAccessTokenStandardRequest(ResourceContext ctx, Princ } Map> rolesByDomain = new HashMap<>(); - int tokenTimeout = 0; + int tokenTimeout = Integer.MAX_VALUE; boolean scopeAdjusted = false; for (String scopeDomainName : scopeDomainNames) { From 0cb9a7c140536fea2b4dd3b786941cf0d5b5079b Mon Sep 17 00:00:00 2001 From: Jeongwoo Kim - jekim Date: Sat, 4 Jul 2026 17:42:27 +0900 Subject: [PATCH 07/30] fix Signed-off-by: Jeongwoo Kim - jekim --- .../zts/src/main/java/com/yahoo/athenz/zts/ZTSImpl.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 52a37dfd78c..53d2571ce27 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,13 +104,14 @@ 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; import com.fasterxml.jackson.core.StreamReadConstraints; import javax.crypto.SecretKey; +import javax.xml.validation.Schema; +import javax.xml.validation.Validator; import static com.yahoo.athenz.common.server.util.config.ConfigManagerSingleton.CONFIG_MANAGER; @@ -3036,7 +3037,10 @@ String[] getSubjectTokenRolesForDomain(Set subjectScopes, final String s continue; } if (subjectScope.startsWith(roleScopePrefix)) { - requestedRoles.add(subjectScope.substring(roleScopePrefix.length())); + String roleName = subjectScope.substring(roleScopePrefix.length()); + if (!roleName.isEmpty()) { + requestedRoles.add(roleName); + } } else if (!subjectScope.contains(":") && requestDomainName.equals(sourceDomainName)) { requestedRoles.add(subjectScope); } From 0a3e77ee698ae616d6ffcc7877b7376f636c5352 Mon Sep 17 00:00:00 2001 From: Aaron Jeongwoo Kim <53258958+mlajkim@users.noreply.github.com> Date: Sun, 5 Jul 2026 06:54:52 +0900 Subject: [PATCH 08/30] Update servers/zts/src/main/java/com/yahoo/athenz/zts/ZTSImpl.java Signed-off-by: Aaron Jeongwoo Kim <53258958+mlajkim@users.noreply.github.com> --- servers/zts/src/main/java/com/yahoo/athenz/zts/ZTSImpl.java | 2 -- 1 file changed, 2 deletions(-) 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 53d2571ce27..e50a6104b03 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 @@ -110,8 +110,6 @@ import com.fasterxml.jackson.core.StreamReadConstraints; import javax.crypto.SecretKey; -import javax.xml.validation.Schema; -import javax.xml.validation.Validator; import static com.yahoo.athenz.common.server.util.config.ConfigManagerSingleton.CONFIG_MANAGER; From 833b75b1171ee042d7ec976974fa34a91e1bac53 Mon Sep 17 00:00:00 2001 From: Aaron Jeongwoo Kim <53258958+mlajkim@users.noreply.github.com> Date: Sun, 5 Jul 2026 06:55:01 +0900 Subject: [PATCH 09/30] Update servers/zts/src/main/java/com/yahoo/athenz/zts/token/OAuthTokenScope.java Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: Aaron Jeongwoo Kim <53258958+mlajkim@users.noreply.github.com> --- .../main/java/com/yahoo/athenz/zts/token/OAuthTokenScope.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 befe19f5ba1..69c89dea552 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 @@ -192,7 +192,7 @@ public OAuthTokenScope(final String scope, int maxDomains, DynamicConfigCsv syst } public String getDomainName() { - return (maxDomains == 1 && domainNames.size() == 1) ? domainNames.stream().findFirst().get() : null; + return (maxDomains == 1 && domainNames.size() == 1) ? domainNames.iterator().next() : null; } public Set getDomainNames() { From ed89507c255901119badb6960054f9e46e189d11 Mon Sep 17 00:00:00 2001 From: Aaron Jeongwoo Kim <53258958+mlajkim@users.noreply.github.com> Date: Sun, 5 Jul 2026 06:55:07 +0900 Subject: [PATCH 10/30] Update servers/zts/src/main/java/com/yahoo/athenz/zts/token/AccessTokenScope.java Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: Aaron Jeongwoo Kim <53258958+mlajkim@users.noreply.github.com> --- .../main/java/com/yahoo/athenz/zts/token/AccessTokenScope.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 86b6fd4d6a1..10ae632a994 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 @@ -57,7 +57,7 @@ public AccessTokenScope(final String scope, final String principalDomain) { @Override public String getDomainName() { - return (domainNames.size() == 1) ? domainNames.stream().findFirst().get() : null; + return (domainNames.size() == 1) ? domainNames.iterator().next() : null; } @Override From 8def6df5431450c8127b9ac300f2ae696b2c73f4 Mon Sep 17 00:00:00 2001 From: Jeongwoo Kim - jekim Date: Sun, 5 Jul 2026 12:38:01 +0900 Subject: [PATCH 11/30] fix: err Signed-off-by: Jeongwoo Kim - jekim --- .../zts/src/main/java/com/yahoo/athenz/zts/ZTSImpl.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 e50a6104b03..09c2f68dc3c 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 @@ -2885,7 +2885,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); @@ -3131,7 +3131,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); @@ -3508,7 +3508,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); @@ -3749,7 +3749,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); From 39e6627b6faa6be6be8db7a168dccd09d556660c Mon Sep 17 00:00:00 2001 From: Jeongwoo Kim - jekim Date: Sun, 5 Jul 2026 13:25:59 +0900 Subject: [PATCH 12/30] fix Signed-off-by: Jeongwoo Kim - jekim --- .../java/com/yahoo/athenz/zts/ZTSConsts.java | 1 + .../java/com/yahoo/athenz/zts/ZTSImpl.java | 48 ++++++++++++++----- .../athenz/zts/token/AccessTokenScope.java | 8 +++- .../zts/token/AccessTokenScopeTest.java | 19 ++++++++ 4 files changed, 62 insertions(+), 14 deletions(-) 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 09c2f68dc3c..d730b2abb35 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 @@ -2838,7 +2838,7 @@ AccessTokenResponse processAccessTokenImpersonationRequest(ResourceContext ctx, // validate that the requested scopes are a subset of the subject token scopes - String[] requestedRoles = tokenExchangeRequestedRoles(accessTokenRequest, subjectToken, requestDomainName); + String[] requestedRoles = tokenExchangeRequestedRoles(accessTokenRequest, subjectToken, requestDomainName, true); if (requestedRoles == null) { throw requestError("Invalid scope for token exchange", caller, requestDomainName, principalDomain); } @@ -2948,6 +2948,11 @@ boolean isPrincipalAuthorizedForTokenTargetExchange(final String targetDomainNam String[] tokenExchangeRequestedRoles(AccessTokenRequest accessTokenRequest, OAuth2Token subjectToken, final String requestDomainName) { + return tokenExchangeRequestedRoles(accessTokenRequest, subjectToken, requestDomainName, false); + } + + String[] tokenExchangeRequestedRoles(AccessTokenRequest accessTokenRequest, OAuth2Token subjectToken, + final String requestDomainName, boolean allowLegacySimpleRoles) { // first let's extract our scope claim from the subject token // and convert that into a set @@ -2971,7 +2976,8 @@ String[] tokenExchangeRequestedRoles(AccessTokenRequest accessTokenRequest, OAut Set scopeRoles = new HashSet<>(Arrays.asList(scopeClaimStr.split("\\s+"))); final String scope = accessTokenRequest.getScope(); if (StringUtil.isEmpty(scope)) { - String[] requestedRoles = getSubjectTokenRolesForDomain(scopeRoles, sourceDomainName, requestDomainName); + String[] requestedRoles = getSubjectTokenRolesForDomain(scopeRoles, sourceDomainName, + requestDomainName, allowLegacySimpleRoles); if (requestedRoles == null || requestedRoles.length == 0) { LOGGER.error("subject token does not contain any roles for requested domain {}", requestDomainName); return null; @@ -2996,13 +3002,15 @@ String[] tokenExchangeRequestedRoles(AccessTokenRequest accessTokenRequest, OAut String[] requestedRoles = tokenScope.getRoleNames(requestDomainName); if (requestedRoles == null) { - requestedRoles = getSubjectTokenRolesForDomain(scopeRoles, sourceDomainName, requestDomainName); + requestedRoles = getSubjectTokenRolesForDomain(scopeRoles, sourceDomainName, + requestDomainName, allowLegacySimpleRoles); 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)) { + } else if (subjectTokenHasRequestedRoles(scopeRoles, sourceDomainName, requestDomainName, + requestedRoles, allowLegacySimpleRoles)) { return requestedRoles; } else { LOGGER.error("requested roles are not a subset of subject token roles"); @@ -3012,12 +3020,19 @@ String[] tokenExchangeRequestedRoles(AccessTokenRequest accessTokenRequest, OAut boolean subjectTokenHasRequestedRoles(Set subjectScopes, final String sourceDomainName, final String requestDomainName, String[] requestedRoles) { + return subjectTokenHasRequestedRoles(subjectScopes, sourceDomainName, requestDomainName, + requestedRoles, false); + } + + boolean subjectTokenHasRequestedRoles(Set subjectScopes, final String sourceDomainName, + final String requestDomainName, String[] requestedRoles, boolean allowLegacySimpleRoles) { for (String requestedRole : requestedRoles) { if (subjectScopes.contains(requestDomainName + OAuthTokenScope.OBJECT_ROLE + requestedRole)) { continue; } - if (requestDomainName.equals(sourceDomainName) && subjectScopes.contains(requestedRole)) { + if ((requestDomainName.equals(sourceDomainName) || allowLegacySimpleRoles) + && subjectScopes.contains(requestedRole)) { continue; } return false; @@ -3027,6 +3042,11 @@ boolean subjectTokenHasRequestedRoles(Set subjectScopes, final String so String[] getSubjectTokenRolesForDomain(Set subjectScopes, final String sourceDomainName, final String requestDomainName) { + return getSubjectTokenRolesForDomain(subjectScopes, sourceDomainName, requestDomainName, false); + } + + String[] getSubjectTokenRolesForDomain(Set subjectScopes, final String sourceDomainName, + final String requestDomainName, boolean allowLegacySimpleRoles) { Set requestedRoles = new HashSet<>(); final String roleScopePrefix = requestDomainName + OAuthTokenScope.OBJECT_ROLE; @@ -3039,7 +3059,8 @@ String[] getSubjectTokenRolesForDomain(Set subjectScopes, final String s if (!roleName.isEmpty()) { requestedRoles.add(roleName); } - } else if (!subjectScope.contains(":") && requestDomainName.equals(sourceDomainName)) { + } else if (!subjectScope.contains(":") + && (requestDomainName.equals(sourceDomainName) || allowLegacySimpleRoles)) { requestedRoles.add(subjectScope); } } @@ -3093,7 +3114,7 @@ AccessTokenResponse processAccessTokenDelegationRequest(ResourceContext ctx, Pri // validate that the requested scopes are a subset of the subject token scopes - String[] requestedRoles = tokenExchangeRequestedRoles(accessTokenRequest, subjectToken, requestDomainName); + String[] requestedRoles = tokenExchangeRequestedRoles(accessTokenRequest, subjectToken, requestDomainName, true); if (requestedRoles == null) { throw requestError("Invalid scope for token exchange", caller, requestDomainName, principalDomain); } @@ -3852,11 +3873,6 @@ AccessTokenResponse processAccessTokenStandardRequest(ResourceContext ctx, Princ ZTSConsts.ZTS_UNKNOWN_DOMAIN, principalDomain); } - if (tokenScope.isOpenIdScope() && tokenScope.getDomainName() == null) { - throw requestError("OpenID access token requests cannot include multiple domains", caller, - domainName, principalDomain); - } - if (!StringUtil.isEmpty(accessTokenRequest.getAuthzDetails()) && scopeDomainNames.size() > 1) { throw requestError("Authorization details cannot be requested for multiple domains", caller, domainName, principalDomain); @@ -4035,7 +4051,13 @@ AccessTokenResponse processAccessTokenStandardRequest(ResourceContext ctx, Princ // set of scopes if (scopeAdjusted || scopeDomainNames.size() > 1 || !scopeDomainNames.contains(domainName)) { - response.setScope(generateScopeResponse(tokenScopes, tokenScope.isOpenIdScope())); + 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 10ae632a994..0632f1f236f 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, "20")); public AccessTokenScope(final String scope, final String principalDomain) { @@ -34,7 +36,7 @@ public AccessTokenScope(final String scope, final String principalDomain) { // :role. // openid :service. - super(scope, Integer.MAX_VALUE, null, supportRolesWithoutDomain ? principalDomain : null); + super(scope, maxDomains, null, supportRolesWithoutDomain ? principalDomain : null); // if we don't have a domain then it's invalid scope @@ -72,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/test/java/com/yahoo/athenz/zts/token/AccessTokenScopeTest.java b/servers/zts/src/test/java/com/yahoo/athenz/zts/token/AccessTokenScopeTest.java index e938093cd1e..6197dc3d633 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 @@ -228,4 +230,21 @@ public void testAccessTokenScopeMultipleDomains() { assertEquals(req4.getDomainNames().size(), 2); assertTrue(req4.sendScopeResponse()); } + + @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:domain news:role.role1", null); + fail(); + } catch (ResourceException ex) { + assertEquals(ex.getCode(), 400); + assertTrue(ex.getMessage().contains("Domain limit: 2 has been reached")); + } + } } From 69cdd0a09f2e170d8be98ced5ec3df0ccedf6fa7 Mon Sep 17 00:00:00 2001 From: Jeongwoo Kim - jekim Date: Sun, 5 Jul 2026 16:23:58 +0900 Subject: [PATCH 13/30] fix Signed-off-by: Jeongwoo Kim - jekim --- .../cert_refresher/src/test/java/com/oath/auth/SocketTest.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/libs/java/cert_refresher/src/test/java/com/oath/auth/SocketTest.java b/libs/java/cert_refresher/src/test/java/com/oath/auth/SocketTest.java index 1b556f8bbf8..f914702b1e6 100644 --- a/libs/java/cert_refresher/src/test/java/com/oath/auth/SocketTest.java +++ b/libs/java/cert_refresher/src/test/java/com/oath/auth/SocketTest.java @@ -92,6 +92,9 @@ private void runPingServer(int port, KeyRefresher keyRefresher) throws Exception while (running) { String line = is.readLine(); + if (line == null) { + break; + } if (line.equals("ping")) { os.write("pong\n".getBytes()); } From e5e007c7eaaf33eac51f85afe848bb477ade128a Mon Sep 17 00:00:00 2001 From: Jeongwoo Kim - jekim Date: Sun, 5 Jul 2026 17:46:31 +0900 Subject: [PATCH 14/30] fix Signed-off-by: Jeongwoo Kim - jekim --- .../com/yahoo/athenz/auth/util/Crypto.java | 19 +++++++++++++++++++ .../yahoo/athenz/auth/util/CryptoTest.java | 10 ++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/libs/java/auth_core/src/main/java/com/yahoo/athenz/auth/util/Crypto.java b/libs/java/auth_core/src/main/java/com/yahoo/athenz/auth/util/Crypto.java index a32f04daf58..4cf04fb8270 100644 --- a/libs/java/auth_core/src/main/java/com/yahoo/athenz/auth/util/Crypto.java +++ b/libs/java/auth_core/src/main/java/com/yahoo/athenz/auth/util/Crypto.java @@ -376,6 +376,19 @@ public static byte[] convertSignatureFromP1363ToDERFormat(byte[] signature, fina } } + static boolean hasInvalidECDSASignatureValue(byte[] signature) { + try { + ASN1Sequence seq = ASN1Sequence.getInstance(signature); + if (seq.size() != 2) { + return false; + } + return ((ASN1Integer) seq.getObjectAt(0)).getValue().signum() <= 0 || + ((ASN1Integer) seq.getObjectAt(1)).getValue().signum() <= 0; + } catch (Exception ex) { + return false; + } + } + /** * Convert signature byte array from ASN.1 DER format to P1363 Format * @param signature byte array in DER format @@ -499,6 +512,9 @@ public static boolean verify(String message, PublicKey key, String signature, try { byte [] sig = ybase64Decode(signature); String signatureAlgorithm = getSignatureAlgorithm(key.getAlgorithm(), digestAlgorithm); + if (signatureAlgorithm.endsWith(ECDSA) && hasInvalidECDSASignatureValue(sig)) { + return false; + } java.security.Signature signer = java.security.Signature.getInstance(signatureAlgorithm, getSignatureProvider()); signer.initVerify(key); signer.update(utf8Bytes(message)); @@ -543,6 +559,9 @@ public static boolean verify(byte[] message, PublicKey key, byte[] signature, String digestAlgorithm) throws CryptoException { try { String signatureAlgorithm = getSignatureAlgorithm(key.getAlgorithm(), digestAlgorithm); + if (signatureAlgorithm.endsWith(ECDSA) && hasInvalidECDSASignatureValue(signature)) { + return false; + } java.security.Signature signer = java.security.Signature.getInstance(signatureAlgorithm, getSignatureProvider()); signer.initVerify(key); signer.update(message); diff --git a/libs/java/auth_core/src/test/java/com/yahoo/athenz/auth/util/CryptoTest.java b/libs/java/auth_core/src/test/java/com/yahoo/athenz/auth/util/CryptoTest.java index 80adf890798..40fbcaacfce 100644 --- a/libs/java/auth_core/src/test/java/com/yahoo/athenz/auth/util/CryptoTest.java +++ b/libs/java/auth_core/src/test/java/com/yahoo/athenz/auth/util/CryptoTest.java @@ -1505,10 +1505,12 @@ public void testNullSignatureValidation() throws NoSuchAlgorithmException { // reset the provider to the jdk one since we default to bc System.setProperty(Crypto.ATHENZ_CRYPTO_SIGNATURE_PROVIDER, "SunEC"); - assertFalse(Crypto.verify(serviceToken.getBytes(StandardCharsets.UTF_8), keyPair.getPublic(), - testDerSignature, Crypto.SHA256)); - - System.clearProperty(Crypto.ATHENZ_CRYPTO_SIGNATURE_PROVIDER); + try { + assertFalse(Crypto.verify(serviceToken.getBytes(StandardCharsets.UTF_8), keyPair.getPublic(), + testDerSignature, Crypto.SHA256)); + } finally { + System.clearProperty(Crypto.ATHENZ_CRYPTO_SIGNATURE_PROVIDER); + } } @Test From fe04cad4f15cdb67b2b61fe893fccc96081fe511 Mon Sep 17 00:00:00 2001 From: Jeongwoo Kim - jekim Date: Mon, 6 Jul 2026 13:30:47 +0900 Subject: [PATCH 15/30] fix Signed-off-by: Jeongwoo Kim - jekim --- .../athenz/zts/ZTSImplAccessTokenTest.java | 157 ++++++++++++++++++ .../zts/token/AccessTokenScopeTest.java | 13 ++ 2 files changed, 170 insertions(+) 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 4ead1a54752..f986820e9ee 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 @@ -608,6 +608,35 @@ public void testPostAccessTokenRequestWithAudienceAndCrossDomainScope() throws J } } + @Test + public void testPostAccessTokenRequestMultipleScopeDomainsScopeDomainNotFound() { + + 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() { @@ -4566,6 +4595,90 @@ 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" }, false)); + assertFalse(zts.subjectTokenHasRequestedRoles( + subjectScopes, "mcp-hub", "api", new String[] { "admin" }, false)); + assertTrue(zts.subjectTokenHasRequestedRoles( + subjectScopes, "mcp-hub", "mcp-hub", new String[] { "api-mcp-accessor" }, false)); + assertFalse(zts.subjectTokenHasRequestedRoles( + subjectScopes, "mcp-hub", "api", new String[] { "api-mcp-accessor" }, false)); + 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", false); + assertEquals(apiRoles.length, 1); + assertEquals(apiRoles[0], "docs-getter"); + + 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", false); + assertEquals(sourceRoles.length, 1); + assertEquals(sourceRoles[0], "api-mcp-accessor"); + } + + @Test + public void testGetSubjectTokenRolesForDomainLegacySimpleRoles() { + Set subjectScopes = new HashSet<>(Arrays.asList("docs-getter")); + + String[] rolesWithoutLegacy = zts.getSubjectTokenRolesForDomain(subjectScopes, "mcp-hub", "api", false); + assertEquals(rolesWithoutLegacy.length, 0); + + String[] rolesWithLegacy = zts.getSubjectTokenRolesForDomain(subjectScopes, "mcp-hub", "api", true); + assertEquals(rolesWithLegacy.length, 1); + assertEquals(rolesWithLegacy[0], "docs-getter"); + } + @Test public void testTokenExchangeRequestedRolesNullScopeClaim() { AccessTokenRequest accessTokenRequest = Mockito.mock(AccessTokenRequest.class); @@ -4730,6 +4843,50 @@ public void testTokenExchangeRequestedRolesCrossDomainScope() { 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", false); + 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", false); + 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", false); + assertNull(requestedRoles); + } + @Test public void testTokenExchangeRequestedRolesIgnoresNonRoleScopes() { AccessTokenRequest accessTokenRequest = Mockito.mock(AccessTokenRequest.class); 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 6197dc3d633..f0247ceb680 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 @@ -247,4 +247,17 @@ public void testAccessTokenScopeMaxDomains() { assertTrue(ex.getMessage().contains("Domain limit: 2 has been reached")); } } + + @Test + public void testAccessTokenScopeMaxDomainsOneLegacyErrorMessage() { + + AccessTokenScope.setMaxDomains(1); + try { + 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")); + } + } } From 30e3e980334973677bb567d95aac60b47eb9cce6 Mon Sep 17 00:00:00 2001 From: Jeongwoo Kim - jekim Date: Mon, 6 Jul 2026 14:40:58 +0900 Subject: [PATCH 16/30] fix Signed-off-by: Jeongwoo Kim - jekim --- servers/zts/src/test/java/com/yahoo/athenz/zts/ZTSTest.java | 6 ++++++ 1 file changed, 6 insertions(+) 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()); + } } From 76ff2de17d8af6716fced83a2aa75e170e139dba Mon Sep 17 00:00:00 2001 From: Aaron Jeongwoo Kim <53258958+mlajkim@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:41:13 +0900 Subject: [PATCH 17/30] Apply suggestion from @mlajkim Signed-off-by: Aaron Jeongwoo Kim <53258958+mlajkim@users.noreply.github.com> --- .../java/com/yahoo/athenz/auth/util/Crypto.java | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/libs/java/auth_core/src/main/java/com/yahoo/athenz/auth/util/Crypto.java b/libs/java/auth_core/src/main/java/com/yahoo/athenz/auth/util/Crypto.java index 4cf04fb8270..89a32f13ca8 100644 --- a/libs/java/auth_core/src/main/java/com/yahoo/athenz/auth/util/Crypto.java +++ b/libs/java/auth_core/src/main/java/com/yahoo/athenz/auth/util/Crypto.java @@ -376,19 +376,6 @@ public static byte[] convertSignatureFromP1363ToDERFormat(byte[] signature, fina } } - static boolean hasInvalidECDSASignatureValue(byte[] signature) { - try { - ASN1Sequence seq = ASN1Sequence.getInstance(signature); - if (seq.size() != 2) { - return false; - } - return ((ASN1Integer) seq.getObjectAt(0)).getValue().signum() <= 0 || - ((ASN1Integer) seq.getObjectAt(1)).getValue().signum() <= 0; - } catch (Exception ex) { - return false; - } - } - /** * Convert signature byte array from ASN.1 DER format to P1363 Format * @param signature byte array in DER format From 005c3d816a981385e426308b7f515aa20f2a171c Mon Sep 17 00:00:00 2001 From: Aaron Jeongwoo Kim <53258958+mlajkim@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:41:20 +0900 Subject: [PATCH 18/30] Apply suggestion from @mlajkim Signed-off-by: Aaron Jeongwoo Kim <53258958+mlajkim@users.noreply.github.com> --- .../src/main/java/com/yahoo/athenz/auth/util/Crypto.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/libs/java/auth_core/src/main/java/com/yahoo/athenz/auth/util/Crypto.java b/libs/java/auth_core/src/main/java/com/yahoo/athenz/auth/util/Crypto.java index 89a32f13ca8..997484bd029 100644 --- a/libs/java/auth_core/src/main/java/com/yahoo/athenz/auth/util/Crypto.java +++ b/libs/java/auth_core/src/main/java/com/yahoo/athenz/auth/util/Crypto.java @@ -546,9 +546,6 @@ public static boolean verify(byte[] message, PublicKey key, byte[] signature, String digestAlgorithm) throws CryptoException { try { String signatureAlgorithm = getSignatureAlgorithm(key.getAlgorithm(), digestAlgorithm); - if (signatureAlgorithm.endsWith(ECDSA) && hasInvalidECDSASignatureValue(signature)) { - return false; - } java.security.Signature signer = java.security.Signature.getInstance(signatureAlgorithm, getSignatureProvider()); signer.initVerify(key); signer.update(message); From 0e75814594cbbf5b64dde0023f72770a606b1125 Mon Sep 17 00:00:00 2001 From: Aaron Jeongwoo Kim <53258958+mlajkim@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:41:27 +0900 Subject: [PATCH 19/30] Apply suggestion from @mlajkim Signed-off-by: Aaron Jeongwoo Kim <53258958+mlajkim@users.noreply.github.com> --- .../src/main/java/com/yahoo/athenz/auth/util/Crypto.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/libs/java/auth_core/src/main/java/com/yahoo/athenz/auth/util/Crypto.java b/libs/java/auth_core/src/main/java/com/yahoo/athenz/auth/util/Crypto.java index 997484bd029..a32f04daf58 100644 --- a/libs/java/auth_core/src/main/java/com/yahoo/athenz/auth/util/Crypto.java +++ b/libs/java/auth_core/src/main/java/com/yahoo/athenz/auth/util/Crypto.java @@ -499,9 +499,6 @@ public static boolean verify(String message, PublicKey key, String signature, try { byte [] sig = ybase64Decode(signature); String signatureAlgorithm = getSignatureAlgorithm(key.getAlgorithm(), digestAlgorithm); - if (signatureAlgorithm.endsWith(ECDSA) && hasInvalidECDSASignatureValue(sig)) { - return false; - } java.security.Signature signer = java.security.Signature.getInstance(signatureAlgorithm, getSignatureProvider()); signer.initVerify(key); signer.update(utf8Bytes(message)); From 2836cdd3400c99a3d3bd5942fe50d9af05fa1b22 Mon Sep 17 00:00:00 2001 From: Aaron Jeongwoo Kim <53258958+mlajkim@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:41:33 +0900 Subject: [PATCH 20/30] Apply suggestion from @mlajkim Signed-off-by: Aaron Jeongwoo Kim <53258958+mlajkim@users.noreply.github.com> --- .../java/com/yahoo/athenz/auth/util/CryptoTest.java | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/libs/java/auth_core/src/test/java/com/yahoo/athenz/auth/util/CryptoTest.java b/libs/java/auth_core/src/test/java/com/yahoo/athenz/auth/util/CryptoTest.java index 40fbcaacfce..80adf890798 100644 --- a/libs/java/auth_core/src/test/java/com/yahoo/athenz/auth/util/CryptoTest.java +++ b/libs/java/auth_core/src/test/java/com/yahoo/athenz/auth/util/CryptoTest.java @@ -1505,12 +1505,10 @@ public void testNullSignatureValidation() throws NoSuchAlgorithmException { // reset the provider to the jdk one since we default to bc System.setProperty(Crypto.ATHENZ_CRYPTO_SIGNATURE_PROVIDER, "SunEC"); - try { - assertFalse(Crypto.verify(serviceToken.getBytes(StandardCharsets.UTF_8), keyPair.getPublic(), - testDerSignature, Crypto.SHA256)); - } finally { - System.clearProperty(Crypto.ATHENZ_CRYPTO_SIGNATURE_PROVIDER); - } + assertFalse(Crypto.verify(serviceToken.getBytes(StandardCharsets.UTF_8), keyPair.getPublic(), + testDerSignature, Crypto.SHA256)); + + System.clearProperty(Crypto.ATHENZ_CRYPTO_SIGNATURE_PROVIDER); } @Test From 378de6dc25e83ad11cb9dfa8f9a0c718f9b3b9fb Mon Sep 17 00:00:00 2001 From: Aaron Jeongwoo Kim <53258958+mlajkim@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:41:40 +0900 Subject: [PATCH 21/30] Apply suggestion from @mlajkim Signed-off-by: Aaron Jeongwoo Kim <53258958+mlajkim@users.noreply.github.com> --- .../cert_refresher/src/test/java/com/oath/auth/SocketTest.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/libs/java/cert_refresher/src/test/java/com/oath/auth/SocketTest.java b/libs/java/cert_refresher/src/test/java/com/oath/auth/SocketTest.java index f914702b1e6..1b556f8bbf8 100644 --- a/libs/java/cert_refresher/src/test/java/com/oath/auth/SocketTest.java +++ b/libs/java/cert_refresher/src/test/java/com/oath/auth/SocketTest.java @@ -92,9 +92,6 @@ private void runPingServer(int port, KeyRefresher keyRefresher) throws Exception while (running) { String line = is.readLine(); - if (line == null) { - break; - } if (line.equals("ping")) { os.write("pong\n".getBytes()); } From e87eb7183d52aed8d0307c3cc495abffd2a3cde8 Mon Sep 17 00:00:00 2001 From: Jeongwoo Kim - jekim Date: Tue, 7 Jul 2026 04:46:00 +0900 Subject: [PATCH 22/30] doc Signed-off-by: Jeongwoo Kim - jekim --- docs/zts_access_token_guide.md | 22 +++++++++++++--------- servers/zts/conf/zts.properties | 4 ++++ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/docs/zts_access_token_guide.md b/docs/zts_access_token_guide.md index ce23eb6dd4d..e4ac15d32ac 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,15 @@ 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. expires_in : requested expiry time for access token in seconds ``` @@ -44,16 +46,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 +116,9 @@ 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. 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 4f3fa79df3d..eb0b70eac78 100644 --- a/servers/zts/conf/zts.properties +++ b/servers/zts/conf/zts.properties @@ -723,6 +723,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 20. +#athenz.zts.access_token_max_domains=20 + # 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) From 9b920e24be106b18900df7ca592d18ea73f5f10d Mon Sep 17 00:00:00 2001 From: Jeongwoo Kim - jekim Date: Fri, 24 Jul 2026 08:20:09 +0900 Subject: [PATCH 23/30] fix Signed-off-by: Jeongwoo Kim - jekim --- servers/zts/src/main/java/com/yahoo/athenz/zts/ZTSImpl.java | 4 ++++ .../java/com/yahoo/athenz/zts/ZTSImplAccessTokenTest.java | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) 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 d730b2abb35..f2cb8847819 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 @@ -2838,6 +2838,8 @@ AccessTokenResponse processAccessTokenImpersonationRequest(ResourceContext ctx, // validate that the requested scopes are a subset of the subject token scopes + // Legacy subject tokens contain only simple role names even when exchanged for a + // different audience. Target-role access and exchange authorization are checked below. String[] requestedRoles = tokenExchangeRequestedRoles(accessTokenRequest, subjectToken, requestDomainName, true); if (requestedRoles == null) { throw requestError("Invalid scope for token exchange", caller, requestDomainName, principalDomain); @@ -3114,6 +3116,8 @@ AccessTokenResponse processAccessTokenDelegationRequest(ResourceContext ctx, Pri // validate that the requested scopes are a subset of the subject token scopes + // Legacy subject tokens contain only simple role names even when exchanged for a + // different audience. Target-role access and exchange authorization are checked below. String[] requestedRoles = tokenExchangeRequestedRoles(accessTokenRequest, subjectToken, requestDomainName, true); if (requestedRoles == null) { throw requestError("Invalid scope for token exchange", caller, requestDomainName, principalDomain); 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 f986820e9ee..52542007816 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 @@ -4603,7 +4603,7 @@ public void testGenerateAccessTokenScopeListMultipleDomains() { rolesByDomain.put("api", new HashSet<>(Arrays.asList("docs-getter"))); rolesByDomain.put("weather", new HashSet<>(Arrays.asList("reader"))); - List scopes = zts.generateAccessTokenScopeList(rolesByDomain, "mcp-hub"); + List scopes = zts.generateAccessTokenScopeList(rolesByDomain, "mcp-hub"); assertEquals(scopes, Arrays.asList( "api:role.docs-getter", From 70bc33ee2bf4dc8c0acb06441fa5bc36ff2af296 Mon Sep 17 00:00:00 2001 From: Jeongwoo Kim - jekim Date: Sat, 25 Jul 2026 14:08:37 +0900 Subject: [PATCH 24/30] fix Signed-off-by: Jeongwoo Kim - jekim --- docs/zts_access_token_guide.md | 3 +- .../java/com/yahoo/athenz/zts/ZTSImpl.java | 41 +--- .../athenz/zts/token/OAuthTokenScope.java | 38 ++-- .../athenz/zts/ZTSImplAccessTokenTest.java | 182 +++++++++++------- .../zts/token/AccessTokenScopeTest.java | 6 + 5 files changed, 152 insertions(+), 118 deletions(-) diff --git a/docs/zts_access_token_guide.md b/docs/zts_access_token_guide.md index e4ac15d32ac..776d72d8873 100644 --- a/docs/zts_access_token_guide.md +++ b/docs/zts_access_token_guide.md @@ -118,7 +118,8 @@ iat : token issue time in seconds (Unix time) exp : token expiry time in seconds (Unix time) 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. + :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/src/main/java/com/yahoo/athenz/zts/ZTSImpl.java b/servers/zts/src/main/java/com/yahoo/athenz/zts/ZTSImpl.java index f2cb8847819..57d9f34e6a0 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 @@ -2838,9 +2838,7 @@ AccessTokenResponse processAccessTokenImpersonationRequest(ResourceContext ctx, // validate that the requested scopes are a subset of the subject token scopes - // Legacy subject tokens contain only simple role names even when exchanged for a - // different audience. Target-role access and exchange authorization are checked below. - String[] requestedRoles = tokenExchangeRequestedRoles(accessTokenRequest, subjectToken, requestDomainName, true); + String[] requestedRoles = tokenExchangeRequestedRoles(accessTokenRequest, subjectToken, requestDomainName); if (requestedRoles == null) { throw requestError("Invalid scope for token exchange", caller, requestDomainName, principalDomain); } @@ -2950,11 +2948,6 @@ boolean isPrincipalAuthorizedForTokenTargetExchange(final String targetDomainNam String[] tokenExchangeRequestedRoles(AccessTokenRequest accessTokenRequest, OAuth2Token subjectToken, final String requestDomainName) { - return tokenExchangeRequestedRoles(accessTokenRequest, subjectToken, requestDomainName, false); - } - - String[] tokenExchangeRequestedRoles(AccessTokenRequest accessTokenRequest, OAuth2Token subjectToken, - final String requestDomainName, boolean allowLegacySimpleRoles) { // first let's extract our scope claim from the subject token // and convert that into a set @@ -2979,7 +2972,7 @@ String[] tokenExchangeRequestedRoles(AccessTokenRequest accessTokenRequest, OAut final String scope = accessTokenRequest.getScope(); if (StringUtil.isEmpty(scope)) { String[] requestedRoles = getSubjectTokenRolesForDomain(scopeRoles, sourceDomainName, - requestDomainName, allowLegacySimpleRoles); + requestDomainName); if (requestedRoles == null || requestedRoles.length == 0) { LOGGER.error("subject token does not contain any roles for requested domain {}", requestDomainName); return null; @@ -2998,21 +2991,20 @@ String[] tokenExchangeRequestedRoles(AccessTokenRequest accessTokenRequest, OAut } // and the scope roles must be a subset of the subject token roles. - // For backward compatibility, simple roles in the subject token belong - // to the subject token audience. Fully qualified roles belong to their - // explicitly named domain. + // 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) { requestedRoles = getSubjectTokenRolesForDomain(scopeRoles, sourceDomainName, - requestDomainName, allowLegacySimpleRoles); + 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, allowLegacySimpleRoles)) { + requestedRoles)) { return requestedRoles; } else { LOGGER.error("requested roles are not a subset of subject token roles"); @@ -3022,19 +3014,12 @@ String[] tokenExchangeRequestedRoles(AccessTokenRequest accessTokenRequest, OAut boolean subjectTokenHasRequestedRoles(Set subjectScopes, final String sourceDomainName, final String requestDomainName, String[] requestedRoles) { - return subjectTokenHasRequestedRoles(subjectScopes, sourceDomainName, requestDomainName, - requestedRoles, false); - } - - boolean subjectTokenHasRequestedRoles(Set subjectScopes, final String sourceDomainName, - final String requestDomainName, String[] requestedRoles, boolean allowLegacySimpleRoles) { for (String requestedRole : requestedRoles) { if (subjectScopes.contains(requestDomainName + OAuthTokenScope.OBJECT_ROLE + requestedRole)) { continue; } - if ((requestDomainName.equals(sourceDomainName) || allowLegacySimpleRoles) - && subjectScopes.contains(requestedRole)) { + if (requestDomainName.equals(sourceDomainName) && subjectScopes.contains(requestedRole)) { continue; } return false; @@ -3044,11 +3029,6 @@ boolean subjectTokenHasRequestedRoles(Set subjectScopes, final String so String[] getSubjectTokenRolesForDomain(Set subjectScopes, final String sourceDomainName, final String requestDomainName) { - return getSubjectTokenRolesForDomain(subjectScopes, sourceDomainName, requestDomainName, false); - } - - String[] getSubjectTokenRolesForDomain(Set subjectScopes, final String sourceDomainName, - final String requestDomainName, boolean allowLegacySimpleRoles) { Set requestedRoles = new HashSet<>(); final String roleScopePrefix = requestDomainName + OAuthTokenScope.OBJECT_ROLE; @@ -3061,8 +3041,7 @@ String[] getSubjectTokenRolesForDomain(Set subjectScopes, final String s if (!roleName.isEmpty()) { requestedRoles.add(roleName); } - } else if (!subjectScope.contains(":") - && (requestDomainName.equals(sourceDomainName) || allowLegacySimpleRoles)) { + } else if (!subjectScope.contains(":") && requestDomainName.equals(sourceDomainName)) { requestedRoles.add(subjectScope); } } @@ -3116,9 +3095,7 @@ AccessTokenResponse processAccessTokenDelegationRequest(ResourceContext ctx, Pri // validate that the requested scopes are a subset of the subject token scopes - // Legacy subject tokens contain only simple role names even when exchanged for a - // different audience. Target-role access and exchange authorization are checked below. - String[] requestedRoles = tokenExchangeRequestedRoles(accessTokenRequest, subjectToken, requestDomainName, true); + String[] requestedRoles = tokenExchangeRequestedRoles(accessTokenRequest, subjectToken, requestDomainName); if (requestedRoles == null) { throw requestError("Invalid scope for token exchange", caller, requestDomainName, principalDomain); } 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 69c89dea552..9e01891d173 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 @@ -36,6 +36,7 @@ public class OAuthTokenScope { public static String OBJECT_ROLES = "roles"; Set domainNames = new HashSet<>(); + Set domainScopeNames = new HashSet<>(); String serviceName = null; Map> roleNames; Map> groupNames; @@ -103,6 +104,7 @@ 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); + domainScopeNames.add(scopeDomainName); sendScopeResponse = true; continue; } @@ -168,26 +170,18 @@ 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 (!scopeRoleNames.isEmpty()) { + roleNames = scopeRoleNames; + } + if (!scopeGroupNames.isEmpty()) { + groupNames = scopeGroupNames; + } + if (sendScopeResponse) { + rolesScope = !scopeRoleNames.isEmpty(); + groupsScope = !scopeGroupNames.isEmpty(); } } @@ -204,6 +198,9 @@ public String getServiceName() { } public String[] getRoleNames(final String domainName) { + if (domainScopeNames.contains(domainName)) { + return null; + } if (roleNames == null) { return null; } @@ -212,6 +209,9 @@ public String[] getRoleNames(final String domainName) { } public Set getGroupNames(final String domainName) { + if (domainScopeNames.contains(domainName)) { + return null; + } if (groupNames == null) { return null; } 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 52542007816..485cadaaa27 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 @@ -608,6 +608,49 @@ public void testPostAccessTokenRequestWithAudienceAndCrossDomainScope() throws J } } + @Test + public void testPostAccessTokenRequestDomainScopeDoesNotWidenOtherDomainRoles() 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"); + + 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 testPostAccessTokenRequestMultipleScopeDomainsScopeDomainNotFound() { @@ -4633,13 +4676,13 @@ public void testSubjectTokenHasRequestedRolesWithFullyQualifiedScopes() { )); assertTrue(zts.subjectTokenHasRequestedRoles( - subjectScopes, "mcp-hub", "api", new String[] { "docs-getter" }, false)); + subjectScopes, "mcp-hub", "api", new String[] { "docs-getter" })); assertFalse(zts.subjectTokenHasRequestedRoles( - subjectScopes, "mcp-hub", "api", new String[] { "admin" }, false)); + subjectScopes, "mcp-hub", "api", new String[] { "admin" })); assertTrue(zts.subjectTokenHasRequestedRoles( - subjectScopes, "mcp-hub", "mcp-hub", new String[] { "api-mcp-accessor" }, false)); + subjectScopes, "mcp-hub", "mcp-hub", new String[] { "api-mcp-accessor" })); assertFalse(zts.subjectTokenHasRequestedRoles( - subjectScopes, "mcp-hub", "api", new String[] { "api-mcp-accessor" }, false)); + subjectScopes, "mcp-hub", "api", new String[] { "api-mcp-accessor" })); assertTrue(zts.subjectTokenHasRequestedRoles( subjectScopes, "mcp-hub", "api", new String[] { "docs-getter" })); } @@ -4654,29 +4697,21 @@ public void testGetSubjectTokenRolesForDomainFullyQualifiedScopes() { "openid" )); - String[] apiRoles = zts.getSubjectTokenRolesForDomain(subjectScopes, "mcp-hub", "api", false); + String[] apiRoles = zts.getSubjectTokenRolesForDomain(subjectScopes, "mcp-hub", "api"); assertEquals(apiRoles.length, 1); assertEquals(apiRoles[0], "docs-getter"); - 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", false); + String[] sourceRoles = zts.getSubjectTokenRolesForDomain(subjectScopes, "mcp-hub", "mcp-hub"); assertEquals(sourceRoles.length, 1); assertEquals(sourceRoles[0], "api-mcp-accessor"); } @Test - public void testGetSubjectTokenRolesForDomainLegacySimpleRoles() { + public void testGetSubjectTokenRolesForDomainSimpleRoleDoesNotCrossDomains() { Set subjectScopes = new HashSet<>(Arrays.asList("docs-getter")); - String[] rolesWithoutLegacy = zts.getSubjectTokenRolesForDomain(subjectScopes, "mcp-hub", "api", false); - assertEquals(rolesWithoutLegacy.length, 0); - - String[] rolesWithLegacy = zts.getSubjectTokenRolesForDomain(subjectScopes, "mcp-hub", "api", true); - assertEquals(rolesWithLegacy.length, 1); - assertEquals(rolesWithLegacy[0], "docs-getter"); + String[] roles = zts.getSubjectTokenRolesForDomain(subjectScopes, "mcp-hub", "api"); + assertEquals(roles.length, 0); } @Test @@ -4853,7 +4888,7 @@ public void testTokenExchangeRequestedRolesFromSubjectTokenScope() { .thenReturn("api-mcp-accessor api:role.docs-getter openid"); Mockito.when(subjectToken.getAudience()).thenReturn("mcp-hub"); - String[] requestedRoles = zts.tokenExchangeRequestedRoles(accessTokenRequest, subjectToken, "api", false); + String[] requestedRoles = zts.tokenExchangeRequestedRoles(accessTokenRequest, subjectToken, "api"); assertNotNull(requestedRoles); assertEquals(requestedRoles.length, 1); assertEquals(requestedRoles[0], "docs-getter"); @@ -4869,7 +4904,7 @@ public void testTokenExchangeRequestedRolesFromSubjectTokenScopeNoTargetRoles() .thenReturn("api-mcp-accessor weather:role.reader openid"); Mockito.when(subjectToken.getAudience()).thenReturn("mcp-hub"); - String[] requestedRoles = zts.tokenExchangeRequestedRoles(accessTokenRequest, subjectToken, "api", false); + String[] requestedRoles = zts.tokenExchangeRequestedRoles(accessTokenRequest, subjectToken, "api"); assertNull(requestedRoles); } @@ -4883,7 +4918,7 @@ public void testTokenExchangeRequestedRolesExplicitScopeNoTargetRolesInSubjectTo .thenReturn("api-mcp-accessor weather:role.reader openid"); Mockito.when(subjectToken.getAudience()).thenReturn("mcp-hub"); - String[] requestedRoles = zts.tokenExchangeRequestedRoles(accessTokenRequest, subjectToken, "api", false); + String[] requestedRoles = zts.tokenExchangeRequestedRoles(accessTokenRequest, subjectToken, "api"); assertNull(requestedRoles); } @@ -4905,7 +4940,7 @@ public void testTokenExchangeRequestedRolesIgnoresNonRoleScopes() { } @Test - public void testTokenExchangeRequestedRolesSimpleRoleBelongsToSourceAudience() { + public void testTokenExchangeRequestedRolesSimpleRoleCannotCrossDomains() { AccessTokenRequest accessTokenRequest = Mockito.mock(AccessTokenRequest.class); OAuth2Token subjectToken = Mockito.mock(OAuth2Token.class); String requestDomainName = "api"; @@ -4918,6 +4953,18 @@ public void testTokenExchangeRequestedRolesSimpleRoleBelongsToSourceAudience() { 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); @@ -5020,7 +5067,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(), @@ -5240,9 +5286,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); @@ -5337,9 +5383,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); @@ -5425,7 +5471,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"); @@ -5489,9 +5535,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); @@ -5589,9 +5635,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); @@ -5691,7 +5737,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); @@ -5747,7 +5793,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); @@ -5803,7 +5849,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); @@ -5861,7 +5907,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); @@ -5877,7 +5923,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" @@ -5886,7 +5932,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 { @@ -5941,7 +5987,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); @@ -6004,7 +6050,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); @@ -6068,8 +6114,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); @@ -6155,7 +6202,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); @@ -6217,7 +6264,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); @@ -6314,7 +6361,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); @@ -6426,7 +6473,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(), @@ -6549,7 +6595,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); @@ -6603,7 +6649,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); @@ -6656,7 +6703,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); @@ -6716,7 +6763,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); @@ -6764,7 +6811,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); @@ -6816,7 +6863,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); @@ -6827,14 +6874,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 { @@ -6872,8 +6919,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); @@ -6927,7 +6974,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); @@ -6981,7 +7028,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"); @@ -7036,7 +7083,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); @@ -7108,7 +7155,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); @@ -7163,7 +7210,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); @@ -7218,7 +7266,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); @@ -7293,7 +7341,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); @@ -7371,8 +7419,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); @@ -7427,7 +7475,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); @@ -7509,7 +7558,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/token/AccessTokenScopeTest.java b/servers/zts/src/test/java/com/yahoo/athenz/zts/token/AccessTokenScopeTest.java index f0247ceb680..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 @@ -224,11 +224,17 @@ public void testAccessTokenScopeMultipleDomains() { 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 From 93e08fb8c7e58e6b9efea06a52c32430a0357a31 Mon Sep 17 00:00:00 2001 From: Jeongwoo Kim - jekim Date: Sat, 25 Jul 2026 15:08:58 +0900 Subject: [PATCH 25/30] Fix OIDC domain scope selectors Signed-off-by: Jeongwoo Kim - jekim --- .../yahoo/athenz/zts/token/OAuthTokenScope.java | 4 ++-- .../yahoo/athenz/zts/token/IdTokenScopeTest.java | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) 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 9e01891d173..1ab4a624a9b 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 @@ -180,8 +180,8 @@ public OAuthTokenScope(final String scope, int maxDomains, DynamicConfigCsv syst groupNames = scopeGroupNames; } if (sendScopeResponse) { - rolesScope = !scopeRoleNames.isEmpty(); - groupsScope = !scopeGroupNames.isEmpty(); + rolesScope = rolesScope || !scopeRoleNames.isEmpty(); + groupsScope = groupsScope || !scopeGroupNames.isEmpty(); } } 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 From f1af7785484d79172e7b6ad68405159b48fbfdaa Mon Sep 17 00:00:00 2001 From: Jeongwoo Kim - jekim Date: Thu, 6 Aug 2026 09:44:08 +0900 Subject: [PATCH 26/30] fix Signed-off-by: Jeongwoo Kim - jekim --- servers/zts/conf/zts.properties | 4 ++-- servers/zts/src/main/java/com/yahoo/athenz/zts/ZTSImpl.java | 2 +- .../java/com/yahoo/athenz/zts/token/AccessTokenScope.java | 2 +- .../java/com/yahoo/athenz/zts/ZTSImplAccessTokenTest.java | 5 +++++ 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/servers/zts/conf/zts.properties b/servers/zts/conf/zts.properties index d29e1b86ba7..60273a6d093 100644 --- a/servers/zts/conf/zts.properties +++ b/servers/zts/conf/zts.properties @@ -761,8 +761,8 @@ athenz.zts.cert_signer_factory_class=com.yahoo.athenz.zts.cert.impl.SelfCertSign #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 20. -#athenz.zts.access_token_max_domains=20 +# 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 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 57d9f34e6a0..775637882a1 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 @@ -3016,7 +3016,7 @@ boolean subjectTokenHasRequestedRoles(Set subjectScopes, final String so final String requestDomainName, String[] requestedRoles) { for (String requestedRole : requestedRoles) { - if (subjectScopes.contains(requestDomainName + OAuthTokenScope.OBJECT_ROLE + requestedRole)) { + if (subjectScopes.contains(ResourceUtils.roleResourceName(requestDomainName, requestedRole))) { continue; } if (requestDomainName.equals(sourceDomainName) && subjectScopes.contains(requestedRole)) { 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 0632f1f236f..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 @@ -25,7 +25,7 @@ public class AccessTokenScope extends OAuthTokenScope { 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, "20")); + System.getProperty(ZTSConsts.ZTS_PROP_ACCESS_TOKEN_MAX_DOMAINS, "1")); public AccessTokenScope(final String scope, final String principalDomain) { 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 485cadaaa27..2054f83eee9 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 @@ -179,6 +179,7 @@ public void setup() { // enable openid scope AccessTokenScope.setSupportOpenIdScope(true); + AccessTokenScope.setMaxDomains(1); } @AfterMethod @@ -187,6 +188,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() { @@ -555,6 +557,7 @@ 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(); @@ -611,6 +614,7 @@ public void testPostAccessTokenRequestWithAudienceAndCrossDomainScope() throws J @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(); @@ -654,6 +658,7 @@ public void testPostAccessTokenRequestDomainScopeDoesNotWidenOtherDomainRoles() @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(); From e7b256aee19edbb7cccb2cd40cb1458603e6a26a Mon Sep 17 00:00:00 2001 From: Jeongwoo Kim - jekim Date: Thu, 6 Aug 2026 17:23:31 +0900 Subject: [PATCH 27/30] fix Signed-off-by: Jeongwoo Kim - jekim --- .../java/com/yahoo/athenz/zts/ZTSImpl.java | 30 ++------- .../athenz/zts/ZTSImplAccessTokenTest.java | 67 ++++++++++++++++++- 2 files changed, 72 insertions(+), 25 deletions(-) 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 775637882a1..d5994d554d2 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 @@ -2872,7 +2872,9 @@ AccessTokenResponse processAccessTokenImpersonationRequest(ResourceContext ctx, final String principalName = principal.getFullName(); for (String role : roles) { - if (!isPrincipalAuthorizedForTokenTargetExchange(requestDomainName, sourceDomainName, role, principal)) { + if (!authorizer.access(ZTSConsts.ZTS_ACTION_TOKEN_TARGET_EXCHANGE, + requestDomainName + ":" + ResourceUtils.roleResourceName(sourceDomainName, role), + principal, null)) { LOGGER.error("processAccessTokenImpersonationRequest: access check failure for {} - {}:{}:role.{}", principalName, requestDomainName, sourceDomainName, role); throw forbiddenError("Principal not authorized for token exchange for the requested role", @@ -2924,28 +2926,6 @@ AccessTokenResponse processAccessTokenImpersonationRequest(ResourceContext ctx, .setScope(generateScopeResponse(roles, requestDomainName, false)); } - boolean isPrincipalAuthorizedForTokenTargetExchange(final String targetDomainName, - final String sourceDomainName, final String role, Principal principal) { - - // Prefer the target role domain for new tokens that can carry fully qualified scopes - // even when their audience is a different source domain. Keep the legacy source-domain - // resource for existing deployments that still authorize exchanges that way. - - final String targetRoleResource = targetDomainName + ":" + - ResourceUtils.roleResourceName(targetDomainName, role); - if (authorizer.access(ZTSConsts.ZTS_ACTION_TOKEN_TARGET_EXCHANGE, targetRoleResource, principal, null)) { - return true; - } - - if (targetDomainName.equals(sourceDomainName)) { - return false; - } - - final String sourceRoleResource = targetDomainName + ":" + - ResourceUtils.roleResourceName(sourceDomainName, role); - return authorizer.access(ZTSConsts.ZTS_ACTION_TOKEN_TARGET_EXCHANGE, sourceRoleResource, principal, null); - } - String[] tokenExchangeRequestedRoles(AccessTokenRequest accessTokenRequest, OAuth2Token subjectToken, final String requestDomainName) { @@ -3120,7 +3100,9 @@ AccessTokenResponse processAccessTokenDelegationRequest(ResourceContext ctx, Pri final String principalName = principal.getFullName(); for (String role : roles) { - if (!isPrincipalAuthorizedForTokenTargetExchange(requestDomainName, sourceDomainName, role, principal)) { + if (!authorizer.access(ZTSConsts.ZTS_ACTION_TOKEN_TARGET_EXCHANGE, + requestDomainName + ":" + ResourceUtils.roleResourceName(sourceDomainName, role), + principal, null)) { LOGGER.error("processAccessTokenDelegationRequest: access check failure for {} - {}:{}:role.{}", principalName, requestDomainName, sourceDomainName, role); throw forbiddenError("Principal not authorized for token exchange for the requested role", 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 2054f83eee9..d65f90a85e7 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 @@ -5128,7 +5128,7 @@ private void addTokenTargetExchangePolicy(String targetDomainName, String source exchangePolicy.setName(generatePolicyName(targetDomainName, "token_target_exchange_" + roleName)); Assertion assertion = new Assertion(); - assertion.setResource(targetDomainName + ":" + ResourceUtils.roleResourceName(targetDomainName, roleName)); + assertion.setResource(targetDomainName + ":" + ResourceUtils.roleResourceName(sourceDomainName, roleName)); assertion.setAction(ZTSConsts.ZTS_ACTION_TOKEN_TARGET_EXCHANGE); assertion.setEffect(com.yahoo.athenz.zms.AssertionEffect.ALLOW); @@ -5167,6 +5167,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"); From f11df9799b1b096c73c91251bebd8cfaeac32d31 Mon Sep 17 00:00:00 2001 From: Jeongwoo Kim - jekim Date: Thu, 6 Aug 2026 18:13:36 +0900 Subject: [PATCH 28/30] fix Signed-off-by: Jeongwoo Kim - jekim --- .../athenz/zts/token/OAuthTokenScope.java | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) 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 1ab4a624a9b..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 @@ -36,7 +36,6 @@ public class OAuthTokenScope { public static String OBJECT_ROLES = "roles"; Set domainNames = new HashSet<>(); - Set domainScopeNames = new HashSet<>(); String serviceName = null; Map> roleNames; Map> groupNames; @@ -70,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 @@ -104,6 +104,9 @@ 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; @@ -173,16 +176,22 @@ public OAuthTokenScope(final String scope, int maxDomains, DynamicConfigCsv syst // Keep explicit role and group filters for every domain. A domain // scope only overrides filters for that specific domain. + 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; } - if (sendScopeResponse) { - rolesScope = rolesScope || !scopeRoleNames.isEmpty(); - groupsScope = groupsScope || !scopeGroupNames.isEmpty(); - } } public String getDomainName() { @@ -198,9 +207,6 @@ public String getServiceName() { } public String[] getRoleNames(final String domainName) { - if (domainScopeNames.contains(domainName)) { - return null; - } if (roleNames == null) { return null; } @@ -209,9 +215,6 @@ public String[] getRoleNames(final String domainName) { } public Set getGroupNames(final String domainName) { - if (domainScopeNames.contains(domainName)) { - return null; - } if (groupNames == null) { return null; } From 6aba3c0150f423d2e154c361cd659ccad2635ed0 Mon Sep 17 00:00:00 2001 From: Jeongwoo Kim - jekim Date: Thu, 6 Aug 2026 18:26:17 +0900 Subject: [PATCH 29/30] doc Signed-off-by: Jeongwoo Kim - jekim --- docs/zts_access_token_guide.md | 3 ++- .../java/com/yahoo/athenz/zts/ZTSImpl.java | 4 +++ .../athenz/zts/ZTSImplAccessTokenTest.java | 25 +++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/docs/zts_access_token_guide.md b/docs/zts_access_token_guide.md index 776d72d8873..14c08184529 100644 --- a/docs/zts_access_token_guide.md +++ b/docs/zts_access_token_guide.md @@ -30,7 +30,8 @@ scope : list of scopes/roles requested in the access token. The caller 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. + 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 ``` 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 d5994d554d2..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 @@ -3828,6 +3828,10 @@ AccessTokenResponse processAccessTokenStandardRequest(ResourceContext ctx, Princ tokenScope.getDomainName() : accessTokenRequest.getAudience().toLowerCase(); setRequestDomain(ctx, domainName); validate(domainName, TYPE_DOMAIN_NAME, principalDomain, caller); + if (!scopeDomainNames.contains(domainName)) { + throw requestError("Audience domain must be one of the scope domains", caller, + domainName, principalDomain); + } DataCache audienceData = dataStore.getDataCache(domainName); if (audienceData == null) { 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 d65f90a85e7..dc8194c07c1 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 @@ -655,6 +655,31 @@ public void testPostAccessTokenRequestDomainScopeDoesNotWidenOtherDomainRoles() } } + @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() { From 5be58d49dd061a700f98d795a604f5101a71a5b6 Mon Sep 17 00:00:00 2001 From: Jeongwoo Kim - jekim Date: Thu, 6 Aug 2026 19:30:47 +0900 Subject: [PATCH 30/30] fix Signed-off-by: Jeongwoo Kim - jekim --- .../test/java/com/yahoo/athenz/zts/ZTSImplAccessTokenTest.java | 2 ++ 1 file changed, 2 insertions(+) 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 dc8194c07c1..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);