diff --git a/libs/java/auth_core/src/main/java/com/yahoo/athenz/auth/token/OAuth2Token.java b/libs/java/auth_core/src/main/java/com/yahoo/athenz/auth/token/OAuth2Token.java index bcc40575bbe..da364adbf2a 100644 --- a/libs/java/auth_core/src/main/java/com/yahoo/athenz/auth/token/OAuth2Token.java +++ b/libs/java/auth_core/src/main/java/com/yahoo/athenz/auth/token/OAuth2Token.java @@ -476,6 +476,19 @@ public boolean setCustomClaim(final String name, final Object value) { return true; } + /** + * Copies non-standard claims from an OAuth2Token that has been parsed and + * validated. Standard claims are ignored and must be set explicitly by the + * caller. + * + * @param sourceToken parsed and validated source token + */ + public void copyCustomClaimsFrom(final OAuth2Token sourceToken) { + for (Map.Entry entry : sourceToken.claimsSet.getClaims().entrySet()) { + setCustomClaim(entry.getKey(), entry.getValue()); + } + } + public Object getClaim(final String name) { return claimsSet.getClaim(name); } diff --git a/libs/java/auth_core/src/test/java/com/yahoo/athenz/auth/token/AccessTokenTest.java b/libs/java/auth_core/src/test/java/com/yahoo/athenz/auth/token/AccessTokenTest.java index aa16d4d5560..2af164fb77e 100644 --- a/libs/java/auth_core/src/test/java/com/yahoo/athenz/auth/token/AccessTokenTest.java +++ b/libs/java/auth_core/src/test/java/com/yahoo/athenz/auth/token/AccessTokenTest.java @@ -252,6 +252,14 @@ public void testAccessTokenWtihCustomClaims() throws JOSEException, ParseExcepti assertEquals(scopes.get(0), "readers"); assertEquals(claimsSet.getClaim("preferred_email"), "noreply@athenz.io"); assertEquals(claimsSet.getClaim("emails"), Arrays.asList(emails)); + + AccessToken parsedToken = new AccessToken(accessJws, publicKey); + AccessToken copiedToken = createAccessToken(now); + copiedToken.copyCustomClaimsFrom(parsedToken); + String copiedJws = copiedToken.getSignedToken(privateKey, "eckey1", "ES256"); + JWTClaimsSet copiedClaims = SignedJWT.parse(copiedJws).getJWTClaimsSet(); + assertEquals(copiedClaims.getClaim("preferred_email"), "noreply@athenz.io"); + assertEquals(copiedClaims.getClaim("emails"), Arrays.asList(emails)); } @Test diff --git a/servers/zts/conf/zts.properties b/servers/zts/conf/zts.properties index e612248f778..9a1629a447a 100644 --- a/servers/zts/conf/zts.properties +++ b/servers/zts/conf/zts.properties @@ -190,6 +190,11 @@ athenz.zts.cert_signer_factory_class=com.yahoo.athenz.zts.cert.impl.SelfCertSign # automatically replace the value with this one #athenz.zts.jag_token_service_max_timeout=21600 +# Specifies the maximum period in seconds from the original ID-JAG auth_time +# during which the ID-JAG may be refreshed. The expiry of an issued or +# refreshed ID-JAG will not exceed auth_time plus this value. +#athenz.zts.jag_token_refresh_max_timeout=2592000 + # Specifies the expiry timeout for signed policy documents that # ZTS Server signs and returns to ZPU clients #athenz.zts.signed_policy_timeout=604800 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..a6fc3526694 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 @@ -118,6 +118,7 @@ public final class ZTSConsts { public static final String ZTS_PROP_JAG_TOKEN_USER_MAX_TIMEOUT = "athenz.zts.jag_token_user_max_timeout"; public static final String ZTS_PROP_JAG_TOKEN_SERVICE_MAX_TIMEOUT = "athenz.zts.jag_token_service_max_timeout"; + public static final String ZTS_PROP_JAG_TOKEN_REFRESH_MAX_TIMEOUT = "athenz.zts.jag_token_refresh_max_timeout"; public static final String ZTS_PROP_SELF_SIGNER_PRIVATE_KEY_FNAME = "athenz.zts.self_signer_private_key_fname"; public static final String ZTS_PROP_SELF_SIGNER_PRIVATE_KEY_PASSWORD = "athenz.zts.self_signer_private_key_password"; 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..15d6f72d0c2 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 @@ -142,6 +142,7 @@ public class ZTSImpl implements ZTSHandler { protected int idTokenDefaultTimeout; protected int jagTokenUserMaxTimeout; protected int jagTokenServiceMaxTimeout; + protected int jagTokenRefreshMaxTimeout; protected DynamicConfigLong x509CertRefreshResetTime; protected long signedPolicyTimeout; protected static String serverHostName = null; @@ -689,6 +690,10 @@ void loadConfigurationSettings() { jagTokenServiceMaxTimeout = Integer.parseInt( System.getProperty(ZTSConsts.ZTS_PROP_JAG_TOKEN_SERVICE_MAX_TIMEOUT, Long.toString(timeout))); + timeout = TimeUnit.SECONDS.convert(30, TimeUnit.DAYS); + jagTokenRefreshMaxTimeout = Integer.parseInt( + System.getProperty(ZTSConsts.ZTS_PROP_JAG_TOKEN_REFRESH_MAX_TIMEOUT, Long.toString(timeout))); + // signedPolicyTimeout is in milliseconds but the config setting should be in seconds // to be consistent with other configuration properties @@ -2753,6 +2758,8 @@ public AccessTokenResponse postAccessTokenRequest(ResourceContext ctx, String re switch (accessTokenRequest.getRequestType()) { case JAG_TOKEN_EXCHANGE: return processJAGTokenIssueRequest(ctx, principal, accessTokenRequest, principalDomain, caller); + case JAG_TOKEN_REFRESH: + return processJAGTokenRefreshRequest(ctx, principal, accessTokenRequest, principalDomain, caller); case JAG_JWT_BEARER: return processJAGTokenExchangeRequest(ctx, principal, accessTokenRequest, principalDomain, caller); case ID_TOKEN_EXCHANGE: @@ -3383,15 +3390,7 @@ AccessTokenResponse processJAGTokenIssueRequest(ResourceContext ctx, Principal p // make sure our principal is authorized to request a jag token // exchange for the given roles - for (String requestedRole : requestedRoles) { - if (!authorizer.access(ZTSConsts.ZTS_ACTION_JAG_EXCHANGE, - ResourceUtils.roleResourceName(domainName, requestedRole), principal, null)) { - LOGGER.error("processJAGTokenIssueRequest: access check failure for {} - {}:role.{}", - principalName, domainName, requestedRole); - throw forbiddenError("Principal not authorized for token exchange for the requested role", - caller, domainName, principalDomain); - } - } + validateJAGExchangeAccess(principal, domainName, requestedRoles, caller, principalDomain); // append the domain name to the role names to make these fully qualified @@ -3400,8 +3399,16 @@ AccessTokenResponse processJAGTokenIssueRequest(ResourceContext ctx, Principal p roleList.add(ResourceUtils.roleResourceName(domainName, subjectRole)); } - int tokenTimeout = determineTokenTimeout(data, subjectRoles, null, accessTokenRequest.getExpiryTime()); long iat = System.currentTimeMillis() / 1000; + long authTime = subjectToken.getAuthTime(); + if (authTime <= 0) { + authTime = iat; + } + int tokenTimeout = determineJAGTokenTimeout( + determineTokenTimeout(data, subjectRoles, null, accessTokenRequest.getExpiryTime()), authTime, iat); + if (tokenTimeout == 0) { + throw requestError("ID-JAG maximum refresh period has expired", caller, domainName, principalDomain); + } AccessToken accessToken = new AccessToken(); accessToken.setVersion(1); @@ -3409,7 +3416,7 @@ AccessTokenResponse processJAGTokenIssueRequest(ResourceContext ctx, Principal p accessToken.setAudience(accessTokenRequest.getAudience()); accessToken.setClientId(principalName); accessToken.setIssueTime(iat); - accessToken.setAuthTime(iat); + accessToken.setAuthTime(authTime); accessToken.setExpiryTime(iat + tokenTimeout); accessToken.setSubject(subjectIdentity); accessToken.setIssuer(issuerResolver.getAccessTokenIssuer(ctx.request(), true)); @@ -3459,6 +3466,165 @@ boolean isAuthenticatedUserSubjectToken(Principal principal, String tokenAudienc return principal.getFullName().equals(subjectIdentity); } + AccessTokenResponse processJAGTokenRefreshRequest(ResourceContext ctx, Principal principal, + AccessTokenRequest accessTokenRequest, final String clientPrincipalDomain, final String caller) { + + disallowAuthorizedServicePrincipal(principal.getAuthorizedService(), "jag token refresh", + caller, clientPrincipalDomain); + + // Renewal is restricted to an Athenz service authenticated with its + // X.509 certificate. User certificates and non-mTLS authentication are + // not sufficient for extending the delegation. + + if (principal.getX509Certificate() == null || userDomain.equals(clientPrincipalDomain)) { + throw authError("ID-JAG refresh requires X.509 authenticated service principal", + caller, ZTSConsts.ZTS_UNKNOWN_DOMAIN, clientPrincipalDomain); + } + + AccessToken jagToken = accessTokenRequest.getJagTokenObj(); + if (!issuerResolver.isOauth2Issuer(jagToken.getIssuer())) { + LOGGER.error("Invalid jag refresh issuer claim: {}", jagToken.getIssuer()); + throw requestError("Unknown jag refresh issuer", caller, ZTSConsts.ZTS_UNKNOWN_DOMAIN, + clientPrincipalDomain); + } + + validateJAGTokenAudience(jagToken, clientPrincipalDomain, caller); + + final String clientPrincipalName = principal.getFullName(); + validateJAGTokenClient(jagToken, clientPrincipalName, clientPrincipalDomain, caller); + + final String scope = jagToken.getScopeStd(); + if (StringUtil.isBlank(scope)) { + throw requestError("Invalid jag assertion - missing scope", caller, ZTSConsts.ZTS_UNKNOWN_DOMAIN, + clientPrincipalDomain); + } + + AccessTokenScope tokenScope = new AccessTokenScope(scope, clientPrincipalDomain); + final String domainName = tokenScope.getDomainName(); + setRequestDomain(ctx, domainName); + validate(domainName, TYPE_DOMAIN_NAME, clientPrincipalDomain, caller); + + String[] requestedRoles = tokenScope.getRoleNames(domainName); + if (requestedRoles == null) { + throw requestError("Scope value does not contain any roles", caller, domainName, clientPrincipalDomain); + } + + DataCache data = dataStore.getDataCache(domainName); + if (data == null) { + throw notFoundError("No such domain: " + domainName, caller, ZTSConsts.ZTS_UNKNOWN_DOMAIN, + clientPrincipalDomain); + } + + for (String requestedRole : requestedRoles) { + validate(requestedRole, TYPE_ENTITY_NAME, clientPrincipalDomain, caller); + } + validateJAGExchangeAccess(principal, domainName, requestedRoles, caller, clientPrincipalDomain); + + // Preserve the lifetime originally selected by ZTS. The validated + // token is ZTS-issued, so its issue and expiry times are trusted. The + // refreshed token must honor the current token limits and end no later + // than the absolute refresh deadline anchored to the original + // auth_time. + + final long originalLifetime = jagToken.getExpiryTime() - jagToken.getIssueTime(); + final int requestedTimeout = (int) Math.min(Math.max(originalLifetime, 1), Integer.MAX_VALUE); + final Set roles = new HashSet<>(Arrays.asList(requestedRoles)); + final int permittedTimeout = determineTokenTimeout(data, roles, null, requestedTimeout); + final long issueTime = System.currentTimeMillis() / 1000; + final int tokenTimeout = determineJAGTokenTimeout(permittedTimeout, jagToken.getAuthTime(), issueTime); + if (tokenTimeout == 0) { + throw requestError("ID-JAG maximum refresh period has expired", caller, domainName, + clientPrincipalDomain); + } + + AccessToken refreshedToken = new AccessToken(); + refreshedToken.setVersion(jagToken.getVersion()); + refreshedToken.setJwtId(UUID.randomUUID().toString()); + refreshedToken.setAudience(jagToken.getAudience()); + refreshedToken.setClientId(jagToken.getClientId()); + refreshedToken.setIssueTime(issueTime); + refreshedToken.setAuthTime(jagToken.getAuthTime()); + refreshedToken.setExpiryTime(issueTime + tokenTimeout); + refreshedToken.setSubject(jagToken.getSubject()); + refreshedToken.setIssuer(jagToken.getIssuer()); + refreshedToken.setScope(jagToken.getScope()); + refreshedToken.setScopeStd(jagToken.getScopeStd()); + refreshedToken.setResource(jagToken.getResource()); + refreshedToken.setAuthorizationDetails(jagToken.getAuthorizationDetails()); + refreshedToken.setUserId(jagToken.getUserId()); + refreshedToken.setProxyPrincipal(jagToken.getProxyPrincipal()); + refreshedToken.setConfirm(jagToken.getConfirm()); + refreshedToken.setAct(jagToken.getAct()); + refreshedToken.setMayAct(jagToken.getMayAct()); + refreshedToken.setPrincipalIssuer(jagToken.getPrincipalIssuer()); + refreshedToken.copyCustomClaimsFrom(jagToken); + + ServerPrivateKey privateKey = getServerPrivateKey(keyAlgoForJsonWebObjects); + String refreshedJwts = refreshedToken.getSignedToken(privateKey.getKey(), privateKey.getId(), + privateKey.getAlgorithm(), AccessToken.HDR_TOKEN_JAG); + + return new AccessTokenResponse().setAccess_token(refreshedJwts).setToken_type(OAUTH_NA_TOKEN) + .setIssued_token_type(OAUTH_JAG_TOKEN).setExpires_in(tokenTimeout) + .setScope(jagToken.getScopeStd()); + } + + int determineJAGTokenTimeout(long requestedTimeout, long authTime, long issueTime) { + + if (requestedTimeout <= 0) { + return 0; + } + + final long maxExpiryTime = authTime + jagTokenRefreshMaxTimeout; + final long remainingRefreshTime = maxExpiryTime - issueTime; + if (remainingRefreshTime <= 0) { + return 0; + } + + return (int) Math.min(Math.min(requestedTimeout, remainingRefreshTime), Integer.MAX_VALUE); + } + + void validateJAGExchangeAccess(Principal principal, final String domainName, String[] requestedRoles, + final String caller, final String principalDomain) { + + for (String requestedRole : requestedRoles) { + if (!authorizer.access(ZTSConsts.ZTS_ACTION_JAG_EXCHANGE, + ResourceUtils.roleResourceName(domainName, requestedRole), principal, null)) { + LOGGER.error("ID-JAG exchange access check failure for {} - {}:role.{}", + principal.getFullName(), domainName, requestedRole); + throw forbiddenError("Principal not authorized for token exchange for the requested role", + caller, domainName, principalDomain); + } + } + } + + void validateJAGTokenAudience(AccessToken jagToken, final String clientPrincipalDomain, final String caller) { + + final String jagAudience = jagToken.getAudience(); + if (!issuerResolver.isOauth2Issuer(jagAudience)) { + LOGGER.error("Invalid jag assertion aud claim: {}", jagAudience); + throw requestError("Unknown jag assertion audience", caller, ZTSConsts.ZTS_UNKNOWN_DOMAIN, + clientPrincipalDomain); + } + } + + void validateJAGTokenClient(AccessToken jagToken, final String clientPrincipalName, + final String clientPrincipalDomain, final String caller) { + + if (!clientPrincipalName.equals(jagToken.getClientId())) { + + // Extract the client ID registered for the service, if present. + + final String clientId = dataStore.getServiceClientId(clientPrincipalDomain, clientPrincipalName); + if (clientId == null || !clientId.equals(jagToken.getClientId())) { + LOGGER.error("Invalid jag assertion client_id claim: {}, clientPrincipalDomain {}, " + + "clientPrincipalName {}, clientId {}", jagToken.getClientId(), + clientPrincipalDomain, clientPrincipalName, clientId); + throw requestError("Invalid jag assertion client_id", caller, ZTSConsts.ZTS_UNKNOWN_DOMAIN, + clientPrincipalDomain); + } + } + } + AccessTokenResponse processJAGTokenExchangeRequest(ResourceContext ctx, Principal principal, AccessTokenRequest accessTokenRequest, final String clientPrincipalDomain, final String caller) { @@ -3475,11 +3641,7 @@ AccessTokenResponse processJAGTokenExchangeRequest(ResourceContext ctx, Principa // next we need to validate that the aud claim MUST match // our server oidc/oauth issuer value - final String jagAudience = jagToken.getAudience(); - if (!issuerResolver.isOauth2Issuer(jagAudience)) { - LOGGER.error("Invalid jag assertion aud claim: {}", jagAudience); - throw requestError("Unknown jag assertion audience", caller, ZTSConsts.ZTS_UNKNOWN_DOMAIN, clientPrincipalDomain); - } + validateJAGTokenAudience(jagToken, clientPrincipalDomain, caller); // finally we need to validate that the client_id claim MUST identify // the same client as the client authentication in the request. If the jag @@ -3490,17 +3652,7 @@ AccessTokenResponse processJAGTokenExchangeRequest(ResourceContext ctx, Principa // the client id in the jag token final String clientPrincipalName = principal.getFullName(); - if (!clientPrincipalName.equals(jagToken.getClientId())) { - - // extract the client-id for the service if one is defined - - final String clientId = dataStore.getServiceClientId(clientPrincipalDomain, clientPrincipalName); - if (clientId == null || !clientId.equals(jagToken.getClientId())) { - LOGGER.error("Invalid jag assertion client_id claim: {}, clientPrincipalDomain {}, clientPrincipalName {}, clientId {}", - jagToken.getClientId(), clientPrincipalDomain, clientPrincipalName, clientId); - throw requestError("Invalid jag assertion client_id", caller, ZTSConsts.ZTS_UNKNOWN_DOMAIN, clientPrincipalDomain); - } - } + validateJAGTokenClient(jagToken, clientPrincipalName, clientPrincipalDomain, caller); // now we need to validate the requested actor parameter. In the context // of OAuth 2.0 Token Exchange (RFC 8693), if the actor is not defined, diff --git a/servers/zts/src/main/java/com/yahoo/athenz/zts/token/AccessTokenRequest.java b/servers/zts/src/main/java/com/yahoo/athenz/zts/token/AccessTokenRequest.java index 25241134869..cf34635dfb0 100644 --- a/servers/zts/src/main/java/com/yahoo/athenz/zts/token/AccessTokenRequest.java +++ b/servers/zts/src/main/java/com/yahoo/athenz/zts/token/AccessTokenRequest.java @@ -48,6 +48,7 @@ public enum RequestType { ACCESS_TOKEN_EXCHANGE, ID_TOKEN_EXCHANGE, JAG_TOKEN_EXCHANGE, + JAG_TOKEN_REFRESH, JAG_JWT_BEARER } @@ -216,8 +217,13 @@ public AccessTokenRequest(final String body, TokenConfigOptions options) { // https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/ if (ZTSConsts.OAUTH_TOKEN_TYPE_JAG.equals(requestedTokenType)) { - requestType = RequestType.JAG_TOKEN_EXCHANGE; - validateJAGTokenExchangeRequest(options); + if (ZTSConsts.OAUTH_TOKEN_TYPE_JAG.equals(subjectTokenType)) { + requestType = RequestType.JAG_TOKEN_REFRESH; + validateJAGTokenRefreshRequest(options); + } else { + requestType = RequestType.JAG_TOKEN_EXCHANGE; + validateJAGTokenExchangeRequest(options); + } } else if (ZTSConsts.OAUTH_TOKEN_TYPE_ID.equals(requestedTokenType)) { requestType = RequestType.ID_TOKEN_EXCHANGE; validateIdTokenExchangeRequest(options); @@ -317,6 +323,30 @@ void validateJAGTokenExchangeRequest(TokenConfigOptions options) { validateClientAssertion(options); } + void validateJAGTokenRefreshRequest(TokenConfigOptions options) { + + // ID-JAG refresh preserves the authorization grant from the current + // ID-JAG. The client cannot request a different scope or audience. + + if (!StringUtil.isBlank(scope)) { + throw new IllegalArgumentException("Invalid request: scope is not supported for ID-JAG refresh"); + } + + if (!StringUtil.isBlank(audience)) { + throw new IllegalArgumentException("Invalid request: audience is not supported for ID-JAG refresh"); + } + + if (StringUtil.isEmpty(subjectToken)) { + throw new IllegalArgumentException("Invalid request: no subject token provided"); + } + + try { + jagTokenObj = new AccessToken(subjectToken, options.getJwtJAGProcessor()); + } catch (Exception ex) { + throw new IllegalArgumentException("Invalid subject ID-JAG: " + ex.getMessage()); + } + } + void validateIdTokenExchangeRequest(TokenConfigOptions options) { // we must have audience specified 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..ab7210fabd3 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 @@ -17,6 +17,13 @@ import com.nimbusds.jose.*; import com.nimbusds.jose.crypto.ECDSASigner; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import com.nimbusds.jose.jwk.source.JWKSource; import com.nimbusds.jose.proc.JWSVerificationKeySelector; import com.nimbusds.jose.proc.SecurityContext; import com.nimbusds.jwt.JWTClaimsSet; @@ -70,6 +77,8 @@ import java.security.PublicKey; import java.security.cert.X509Certificate; import java.security.interfaces.ECPrivateKey; +import java.security.interfaces.ECPublicKey; +import java.security.interfaces.RSAPublicKey; import java.text.ParseException; import java.time.Instant; import java.util.*; @@ -187,18 +196,40 @@ 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); + System.clearProperty(ZTSConsts.ZTS_PROP_JAG_TOKEN_REFRESH_MAX_TIMEOUT); } private ConfigurableJWTProcessor createJAGProcessor() { + return createJAGProcessor(null); + } + + private ConfigurableJWTProcessor createJAGProcessor(ServerPrivateKey serverPrivateKey) { + final String jwksUri = Objects.requireNonNull(classLoader.getResource("jwt_jwks.json")).toString(); JwtsSigningKeyResolver resolver = new JwtsSigningKeyResolver(jwksUri, null, null, true); + JWKSource keySource = resolver.getKeySource(); + if (serverPrivateKey != null) { + PublicKey publicKey = Crypto.extractPublicKey(serverPrivateKey.getKey()); + JWK ztsKey; + if (publicKey instanceof RSAPublicKey) { + ztsKey = new RSAKey.Builder((RSAPublicKey) publicKey).keyID(serverPrivateKey.getId()).build(); + } else { + ECPublicKey ecPublicKey = (ECPublicKey) publicKey; + ztsKey = new ECKey.Builder(Curve.forECParameterSpec(ecPublicKey.getParams()), ecPublicKey) + .keyID(serverPrivateKey.getId()).build(); + } + JwtsHelper.CompositeJWKSource compositeSource = new JwtsHelper.CompositeJWKSource<>(); + compositeSource.addKeySource(keySource); + compositeSource.addKeySource(new ImmutableJWKSet<>(new JWKSet(ztsKey))); + keySource = compositeSource; + } ConfigurableJWTProcessor jwtProcessor = new DefaultJWTProcessor<>(); jwtProcessor.setJWSTypeVerifier(JwtsHelper.JWT_JAG_TYPE_VERIFIER); jwtProcessor.setJWSKeySelector(new JWSVerificationKeySelector<>(JwtsHelper.JWS_SUPPORTED_ALGORITHMS, - resolver.getKeySource())); + keySource)); return jwtProcessor; } @@ -1965,6 +1996,41 @@ private String createJagToken(PrivateKey key, String keyId, String subject, Stri return createJagToken(key, keyId, subject, clientId, scope, audience, expiryTime, null); } + private String createZTSJagToken(ServerPrivateKey serverPrivateKey, String issuer, String audience, + String clientId, String scope) { + + long now = System.currentTimeMillis() / 1000; + AccessToken accessToken = new AccessToken(); + accessToken.setVersion(1); + accessToken.setJwtId(UUID.randomUUID().toString()); + accessToken.setIssuer(issuer); + accessToken.setAudience(audience); + accessToken.setClientId(clientId); + accessToken.setSubject("user_domain.user"); + accessToken.setIssueTime(now); + accessToken.setAuthTime(now); + accessToken.setExpiryTime(now + 3600); + accessToken.setScopeStd(scope); + return accessToken.getSignedToken(serverPrivateKey.getKey(), serverPrivateKey.getId(), + serverPrivateKey.getAlgorithm(), AccessToken.HDR_TOKEN_JAG); + } + + private void assertJAGRefreshFailure(ZTSImpl ztsImpl, ResourceContext context, String jagToken, + int expectedCode, String expectedMessage) { + + String refreshRequest = "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" + + "&requested_token_type=urn:ietf:params:oauth:token-type:id-jag" + + "&subject_token=" + jagToken + + "&subject_token_type=urn:ietf:params:oauth:token-type:id-jag"; + try { + ztsImpl.postAccessTokenRequest(context, refreshRequest); + fail("Expected ID-JAG refresh failure"); + } catch (ResourceException ex) { + assertEquals(ex.getCode(), expectedCode); + assertTrue(ex.getMessage().contains(expectedMessage)); + } + } + @Test public void testProcessJAGTokenExchangeRequestImpersonationSuccess() throws JOSEException { @@ -3088,6 +3154,12 @@ private String createClientAssertionToken(PrivateKey privateKey) { private String createIdToken(PrivateKey privateKey, String keyId, String subject, String audience, long expiryTime, String preferredEmail, String athenzCode) { + return createIdToken(privateKey, keyId, subject, audience, expiryTime, preferredEmail, athenzCode, true); + } + + private String createIdToken(PrivateKey privateKey, String keyId, String subject, + String audience, long expiryTime, String preferredEmail, String athenzCode, + boolean includeAuthTime) { try { JWSSigner signer = JwtsHelper.getJWSSigner(privateKey); long now = System.currentTimeMillis() / 1000; @@ -3097,8 +3169,10 @@ private String createIdToken(PrivateKey privateKey, String keyId, String subject .expirationTime(Date.from(Instant.ofEpochSecond(expiryTime))) .issuer("https://athenz.io:4443/zts/v1") .audience(audience) - .claim("ver", 1) - .claim("auth_time", now); + .claim("ver", 1); + if (includeAuthTime) { + builder.claim("auth_time", now); + } if (preferredEmail != null) { builder.claim("preferred_email", preferredEmail); } @@ -3197,6 +3271,8 @@ public void testProcessJAGTokenIssueRequestSuccess() throws JOSEException { assertEquals(claimSet.getAudience().get(0), "https://athenz.io"); assertEquals(claimSet.getIssuer(), ztsImpl.ztsOpenIDIssuer); assertEquals(claimSet.getStringClaim("client_id"), "user_domain.proxy-user1"); + assertEquals(claimSet.getLongClaim("auth_time"), + SignedJWT.parse(subjectToken).getJWTClaimsSet().getLongClaim("auth_time")); List scopes = claimSet.getStringListClaim("scp"); assertNotNull(scopes); @@ -4414,6 +4490,214 @@ public void testProcessJAGTokenIssueRequestSuccessWithServiceClientId() throws J cloudStore.close(); } + @Test + public void testProcessJAGTokenRefreshRequest() throws Exception { + + System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, + "src/test/resources/unit_test_zts_at_private.pem"); + + TokenExchangeIdentityProvider provider = new TokenExchangeIdentityProvider() { + @Override + public String getTokenIdentity(OAuth2Token token) { + return token.getSubject(); + } + + @Override + public String getTokenAudience(OAuth2Token token) { + return token.getAudience(); + } + + @Override + public List getTokenExchangeClaims() { + return List.of("preferred_email", "athenz_code"); + } + }; + + CloudStore cloudStore = new CloudStore(); + System.setProperty(ZTSConsts.ZTS_PROP_JAG_TOKEN_REFRESH_MAX_TIMEOUT, "200"); + ZTSImpl ztsImpl = new ZTSImpl(cloudStore, store); + assertEquals(ztsImpl.jagTokenRefreshMaxTimeout, 200); + ztsImpl.userDomain = "user_domain"; + ztsImpl.tokenConfigOptions.setJwtIDTProcessor(createIDTokenProcessor()); + ServerPrivateKey serverPrivateKey = getServerPrivateKey(ztsImpl, ztsImpl.keyAlgoForJsonWebObjects); + ztsImpl.tokenConfigOptions.setJwtJAGProcessor(createJAGProcessor(serverPrivateKey)); + ztsImpl.providerConfigManager.putProvider(ztsImpl.ztsOpenIDIssuer, provider); + + System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, + "src/test/resources/unit_test_zts_private.pem"); + + SignedDomain signedDomain = createSignedDomain("coretech", "weather", "storage", true); + store.processSignedDomain(signedDomain, false); + addJAGExchangePolicy("coretech", "coretech.jwt", "writers"); + + final File ecPrivateKey = new File("./src/test/resources/unit_test_zts_private_ec.pem"); + PrivateKey privateKey = Crypto.loadPrivateKey(ecPrivateKey); + long expiryTime = System.currentTimeMillis() / 1000 + 3600; + String idToken = createIdToken(privateKey, "0", "user_domain.user", "coretech.jwt", + expiryTime, "john.doe@athenz.io", "athenz-code"); + + Principal principal = SimplePrincipal.create("coretech", "jwt", + "x509-certificate-details", 0, new CertificateAuthority()); + assertNotNull(principal); + ((SimplePrincipal) principal).setX509Certificate(Mockito.mock(X509Certificate.class)); + ResourceContext context = createResourceContext(principal); + + String issueRequest = "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" + + "&requested_token_type=urn:ietf:params:oauth:token-type:id-jag" + + "&subject_token=" + idToken + + "&audience=" + ztsImpl.ztsOAuthIssuer + + "&subject_token_type=urn:ietf:params:oauth:token-type:id_token" + + "&scope=coretech:role.writers"; + + AccessTokenResponse issuedResponse = ztsImpl.postAccessTokenRequest(context, issueRequest); + String issuedToken = issuedResponse.getAccess_token(); + assertNotNull(issuedToken); + assertTrue(issuedResponse.getExpires_in() > 0); + assertTrue(issuedResponse.getExpires_in() <= 200); + + String idTokenWithoutAuthTime = createIdToken(privateKey, "0", "user_domain.user", "coretech.jwt", + expiryTime, "john.doe@athenz.io", "athenz-code", false); + long fallbackAuthTimeStart = System.currentTimeMillis() / 1000; + AccessTokenResponse fallbackResponse = ztsImpl.postAccessTokenRequest(context, + issueRequest.replace(idToken, idTokenWithoutAuthTime)); + long fallbackAuthTimeEnd = System.currentTimeMillis() / 1000; + long fallbackAuthTime = SignedJWT.parse(fallbackResponse.getAccess_token()).getJWTClaimsSet() + .getLongClaim("auth_time"); + assertTrue(fallbackAuthTime >= fallbackAuthTimeStart); + assertTrue(fallbackAuthTime <= fallbackAuthTimeEnd); + + ztsImpl.jagTokenRefreshMaxTimeout = 100; + + String refreshRequest = "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" + + "&requested_token_type=urn:ietf:params:oauth:token-type:id-jag" + + "&subject_token=" + issuedToken + + "&subject_token_type=urn:ietf:params:oauth:token-type:id-jag"; + + AccessTokenResponse refreshedResponse = ztsImpl.postAccessTokenRequest(context, refreshRequest); + assertEquals(refreshedResponse.getToken_type(), "N_A"); + assertEquals(refreshedResponse.getIssued_token_type(), + "urn:ietf:params:oauth:token-type:id-jag"); + assertEquals(refreshedResponse.getScope(), "coretech:role.writers"); + + JWSVerifier verifier = JwtsHelper.getJWSVerifier(Crypto.extractPublicKey(serverPrivateKey.getKey())); + SignedJWT issuedJwt = SignedJWT.parse(issuedToken); + SignedJWT refreshedJwt = SignedJWT.parse(refreshedResponse.getAccess_token()); + assertTrue(refreshedJwt.verify(verifier)); + + JWTClaimsSet issuedClaims = issuedJwt.getJWTClaimsSet(); + JWTClaimsSet refreshedClaims = refreshedJwt.getJWTClaimsSet(); + assertNotEquals(refreshedClaims.getJWTID(), issuedClaims.getJWTID()); + assertEquals(refreshedClaims.getSubject(), issuedClaims.getSubject()); + assertEquals(refreshedClaims.getAudience(), issuedClaims.getAudience()); + assertEquals(refreshedClaims.getIssuer(), issuedClaims.getIssuer()); + assertEquals(refreshedClaims.getStringClaim("client_id"), issuedClaims.getStringClaim("client_id")); + assertEquals(refreshedClaims.getStringListClaim("scp"), issuedClaims.getStringListClaim("scp")); + assertEquals(refreshedClaims.getLongClaim("auth_time"), issuedClaims.getLongClaim("auth_time")); + assertEquals(refreshedClaims.getStringClaim("preferred_email"), "john.doe@athenz.io"); + assertEquals(refreshedClaims.getStringClaim("athenz_code"), "athenz-code"); + + long refreshDeadline = issuedClaims.getLongClaim("auth_time") + ztsImpl.jagTokenRefreshMaxTimeout; + long refreshedIssueTime = refreshedClaims.getIssueTime().toInstant().getEpochSecond(); + long refreshedExpiryTime = refreshedClaims.getExpirationTime().toInstant().getEpochSecond(); + assertEquals(refreshedExpiryTime, refreshDeadline); + assertEquals(refreshedResponse.getExpires_in().longValue(), refreshDeadline - refreshedIssueTime); + + int roleTokenMaxTimeout = ztsImpl.roleTokenMaxTimeout; + ztsImpl.jagTokenRefreshMaxTimeout = 200; + ztsImpl.roleTokenMaxTimeout = 50; + AccessTokenResponse roleLimitedResponse = ztsImpl.postAccessTokenRequest(context, refreshRequest); + JWTClaimsSet roleLimitedClaims = SignedJWT.parse(roleLimitedResponse.getAccess_token()).getJWTClaimsSet(); + long roleLimitedLifetime = roleLimitedClaims.getExpirationTime().toInstant().getEpochSecond() + - roleLimitedClaims.getIssueTime().toInstant().getEpochSecond(); + assertEquals(roleLimitedResponse.getExpires_in().intValue(), 50); + assertEquals(roleLimitedLifetime, 50); + assertTrue(roleLimitedClaims.getExpirationTime().toInstant().getEpochSecond() + <= issuedClaims.getLongClaim("auth_time") + ztsImpl.jagTokenRefreshMaxTimeout); + ztsImpl.roleTokenMaxTimeout = roleTokenMaxTimeout; + + assertJAGRefreshFailure(ztsImpl, context, createZTSJagToken(serverPrivateKey, ztsImpl.ztsOpenIDIssuer, + ztsImpl.ztsOAuthIssuer, "coretech.jwt", null), ResourceException.BAD_REQUEST, + "Invalid jag assertion - missing scope"); + assertJAGRefreshFailure(ztsImpl, context, createZTSJagToken(serverPrivateKey, ztsImpl.ztsOpenIDIssuer, + ztsImpl.ztsOAuthIssuer, "coretech.jwt", "coretech:domain"), ResourceException.BAD_REQUEST, + "Scope value does not contain any roles"); + assertJAGRefreshFailure(ztsImpl, context, createZTSJagToken(serverPrivateKey, ztsImpl.ztsOpenIDIssuer, + ztsImpl.ztsOAuthIssuer, "coretech.jwt", "unknown:role.writers"), ResourceException.NOT_FOUND, + "No such domain: unknown"); + + Principal noCertPrincipal = SimplePrincipal.create("coretech", "jwt", "token", 0, null); + try { + ztsImpl.postAccessTokenRequest(createResourceContext(noCertPrincipal), refreshRequest); + fail("Expected an authentication error without a service certificate"); + } catch (ResourceException ex) { + assertEquals(ex.getCode(), ResourceException.UNAUTHORIZED); + } + + Principal userCertPrincipal = SimplePrincipal.create("user_domain", "user", + "x509-certificate-details", 0, new CertificateAuthority()); + ((SimplePrincipal) userCertPrincipal).setX509Certificate(Mockito.mock(X509Certificate.class)); + try { + ztsImpl.postAccessTokenRequest(createResourceContext(userCertPrincipal), refreshRequest); + fail("Expected an authentication error for a user certificate"); + } catch (ResourceException ex) { + assertEquals(ex.getCode(), ResourceException.UNAUTHORIZED); + } + + Principal wrongClientPrincipal = SimplePrincipal.create("coretech", "storage", + "x509-certificate-details", 0, new CertificateAuthority()); + ((SimplePrincipal) wrongClientPrincipal).setX509Certificate(Mockito.mock(X509Certificate.class)); + try { + ztsImpl.postAccessTokenRequest(createResourceContext(wrongClientPrincipal), refreshRequest); + fail("Expected a client binding error"); + } catch (ResourceException ex) { + assertEquals(ex.getCode(), ResourceException.BAD_REQUEST); + assertTrue(ex.getMessage().contains("Invalid jag assertion client_id")); + } + + String invalidIssuerToken = createJagToken(privateKey, "0", "user_domain.user", "coretech.jwt", + "coretech:role.writers", ztsImpl.ztsOAuthIssuer, expiryTime); + String invalidIssuerRequest = "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" + + "&requested_token_type=urn:ietf:params:oauth:token-type:id-jag" + + "&subject_token=" + invalidIssuerToken + + "&subject_token_type=urn:ietf:params:oauth:token-type:id-jag"; + try { + ztsImpl.postAccessTokenRequest(context, invalidIssuerRequest); + fail("Expected an issuer validation error"); + } catch (ResourceException ex) { + assertEquals(ex.getCode(), ResourceException.BAD_REQUEST); + assertTrue(ex.getMessage().contains("Unknown jag refresh issuer")); + } + + ztsImpl.jagTokenRefreshMaxTimeout = 0; + try { + ztsImpl.postAccessTokenRequest(context, issueRequest); + fail("Expected initial ID-JAG refresh period expiry error"); + } catch (ResourceException ex) { + assertEquals(ex.getCode(), ResourceException.BAD_REQUEST); + assertTrue(ex.getMessage().contains("ID-JAG maximum refresh period has expired")); + } + try { + ztsImpl.postAccessTokenRequest(context, refreshRequest); + fail("Expected refresh period expiry error"); + } catch (ResourceException ex) { + assertEquals(ex.getCode(), ResourceException.BAD_REQUEST); + assertTrue(ex.getMessage().contains("ID-JAG maximum refresh period has expired")); + } + + ztsImpl.jagTokenRefreshMaxTimeout = 200; + store.processSignedDomain(createSignedDomain("coretech", "weather", "storage", true), false); + try { + ztsImpl.postAccessTokenRequest(context, refreshRequest); + fail("Expected JAG exchange authorization error after policy removal"); + } catch (ResourceException ex) { + assertEquals(ex.getCode(), ResourceException.FORBIDDEN); + assertTrue(ex.getMessage().contains( + "Principal not authorized for token exchange for the requested role")); + } + + cloudStore.close(); + } + @Test public void testGenerateScopeResponseEmptyRoles() { Set roles = new HashSet<>(); diff --git a/servers/zts/src/test/java/com/yahoo/athenz/zts/ZTSImplTest.java b/servers/zts/src/test/java/com/yahoo/athenz/zts/ZTSImplTest.java index 9ba5af366fa..e74bc9d68df 100644 --- a/servers/zts/src/test/java/com/yahoo/athenz/zts/ZTSImplTest.java +++ b/servers/zts/src/test/java/com/yahoo/athenz/zts/ZTSImplTest.java @@ -275,6 +275,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); + System.clearProperty(ZTSConsts.ZTS_PROP_JAG_TOKEN_REFRESH_MAX_TIMEOUT); ZTSUtils.ZTS_CERT_INSTANCE_ID_DNS_NAMES.remove(".instanceid.athenz.zts.athenz.cloud"); ZTSUtils.ZTS_CERT_INSTANCE_ID_DNS_NAMES.remove(".instanceid.athenz.ostk.athenz.cloud"); } @@ -2859,6 +2860,15 @@ public void testDetermineTokeTimeoutMinValueMaxExceeded() { assertEquals(zts.determineTokenTimeout(dataCache, Collections.emptySet(), 120000, null), roleTokenMaxTimeout); } + @Test + public void testDetermineJAGTokenTimeout() { + zts.jagTokenRefreshMaxTimeout = 100; + assertEquals(zts.determineJAGTokenTimeout(50, 1000, 1000), 50); + assertEquals(zts.determineJAGTokenTimeout(50, 1000, 1075), 25); + assertEquals(zts.determineJAGTokenTimeout(50, 1000, 1100), 0); + assertEquals(zts.determineJAGTokenTimeout(0, 1000, 1000), 0); + } + @Test public void testRoleTokenAddrNoLoopback() { HttpServletRequest servletRequest = Mockito.mock(HttpServletRequest.class); diff --git a/servers/zts/src/test/java/com/yahoo/athenz/zts/token/AccessTokenRequestTest.java b/servers/zts/src/test/java/com/yahoo/athenz/zts/token/AccessTokenRequestTest.java index 16aaecf12b4..2128b74dfa7 100644 --- a/servers/zts/src/test/java/com/yahoo/athenz/zts/token/AccessTokenRequestTest.java +++ b/servers/zts/src/test/java/com/yahoo/athenz/zts/token/AccessTokenRequestTest.java @@ -357,6 +357,60 @@ public void testAccessTokenRequestTokenExchange() { assertEquals(request.getRequestType(), AccessTokenRequest.RequestType.JAG_TOKEN_EXCHANGE); } + @Test + public void testAccessTokenRequestJAGTokenRefresh() { + + final File ecPrivateKey = new File("./src/test/resources/unit_test_zts_private_ec.pem"); + PrivateKey privateKey = Crypto.loadPrivateKey(ecPrivateKey); + long expiryTime = System.currentTimeMillis() / 1000 + 3600; + String subjectToken = createToken(privateKey, "0", "user_domain.user", + "https://athenz.io:4443/zts/v1", expiryTime, AccessToken.HDR_TOKEN_JAG); + + final String requestBody = "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" + + "&requested_token_type=urn:ietf:params:oauth:token-type:id-jag" + + "&subject_token=" + subjectToken + + "&subject_token_type=urn:ietf:params:oauth:token-type:id-jag"; + + AccessTokenRequest request = new AccessTokenRequest(requestBody, defaultConfigOptions); + assertEquals(request.getRequestType(), AccessTokenRequest.RequestType.JAG_TOKEN_REFRESH); + assertNotNull(request.getJagTokenObj()); + assertNull(request.getScope()); + assertNull(request.getAudience()); + + try { + new AccessTokenRequest(requestBody + "&scope=coretech:role.writers", defaultConfigOptions); + fail(); + } catch (IllegalArgumentException ex) { + assertEquals(ex.getMessage(), "Invalid request: scope is not supported for ID-JAG refresh"); + } + + try { + new AccessTokenRequest(requestBody + "&audience=https://athenz.io", defaultConfigOptions); + fail(); + } catch (IllegalArgumentException ex) { + assertEquals(ex.getMessage(), "Invalid request: audience is not supported for ID-JAG refresh"); + } + + try { + new AccessTokenRequest("grant_type=urn:ietf:params:oauth:grant-type:token-exchange" + + "&requested_token_type=urn:ietf:params:oauth:token-type:id-jag" + + "&subject_token_type=urn:ietf:params:oauth:token-type:id-jag", defaultConfigOptions); + fail(); + } catch (IllegalArgumentException ex) { + assertEquals(ex.getMessage(), "Invalid request: no subject token provided"); + } + + try { + new AccessTokenRequest("grant_type=urn:ietf:params:oauth:grant-type:token-exchange" + + "&requested_token_type=urn:ietf:params:oauth:token-type:id-jag" + + "&subject_token=invalid" + + "&subject_token_type=urn:ietf:params:oauth:token-type:id-jag", defaultConfigOptions); + fail(); + } catch (IllegalArgumentException ex) { + assertTrue(ex.getMessage().startsWith("Invalid subject ID-JAG: ")); + } + } + @Test public void testAccessTokenRequestTokenExchangeWithResource() {