Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/zts_token_exchange_requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,15 @@ Requirements:
- Validates that the subject identity has access to at least one of the requested roles
The generated token will only include roles that the subject identity has access to.

## ID Token Exchange

The ID token exchange feature allows a service to exchange an existing ID token for a new
ID token that includes Athenz role/group information for a requested audience.

Notes:

- If the `subject_token` includes a SPIFFE ID in the `spiffe` claim, ZTS copies it into the issued ID token `spiffe` claim.

## Access Token Exchange (Impersonation)
Specification: https://datatracker.ietf.org/doc/html/rfc8693

Expand All @@ -91,6 +100,7 @@ Requirements:
- The token request must have a valid `audience` parameter specified
- The token request must have the `scope` parameter set to the list of roles being requested in the
format: `{domainName}:role.{roleName} {domainName}:role.{roleName} ...`
- If the `subject_token` includes a SPIFFE ID in the `spiffe` claim, ZTS copies it into the issued access token `spiffe` claim.

1. Role Names Validation

Expand Down Expand Up @@ -131,6 +141,7 @@ Requirements:
parameter set to `urn:ietf:params:oauth:token-type:id_token`, `urn:ietf:params:oauth:token-type:id-access-token` or
`urn:ietf:params:oauth:token-type:jwt`
- The subject_token must have a valid `may_act` claim that includes a `sub` claim that matches the actor_token's subject
- If the `subject_token` includes a SPIFFE ID in the `spiffe` claim, ZTS copies it into the issued access token `spiffe` claim.

1. Role Names Validation

Expand Down
84 changes: 80 additions & 4 deletions servers/zts/src/main/java/com/yahoo/athenz/zts/ZTSImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -2620,6 +2620,27 @@ AccessTokenResponse processAccessTokenExchangeRequest(ResourceContext ctx, Princ
}
}

String extractSpiffeIdFromToken(final OAuth2Token token) {
if (token == null) {
return null;
}

final Object spiffeClaim = token.getClaim(IdToken.CLAIM_SPIFFE);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so the claim itself is called spiffe but that's only valid for ZTS itself. If we're doing an exchange from another identity provider, then most likely this isn't going to work.

if it's a spiffe svid, then spiffie uri is in the sub claim and that's supported.

So if it's any other claim used by that identity provider then it's not supported. This may not be a problem if we're only dealing with ZTS tokens or spiffe svids but something to be aware of. maybe we can add comments about this limitation

if (spiffeClaim != null) {
final String spiffeId = spiffeClaim.toString();
if (!StringUtil.isEmpty(spiffeId) && spiffeId.startsWith(ZTSConsts.ZTS_CERT_SPIFFE_URI)) {
return spiffeId;
}
}

final String subject = token.getSubject();
if (!StringUtil.isEmpty(subject) && subject.startsWith(ZTSConsts.ZTS_CERT_SPIFFE_URI)) {
return subject;
}

return null;
}

AccessTokenResponse processAccessTokenImpersonationRequest(ResourceContext ctx, Principal principal,
AccessTokenRequest accessTokenRequest, final String principalDomain, final String caller) {

Expand Down Expand Up @@ -2710,6 +2731,11 @@ AccessTokenResponse processAccessTokenImpersonationRequest(ResourceContext ctx,
accessToken.setIssuer(issuerResolver.getAccessTokenIssuer(ctx.request(), accessTokenRequest.isUseOpenIDIssuer()));
accessToken.setScope(new ArrayList<>(roles));

final String spiffeId = extractSpiffeIdFromToken(subjectToken);
if (spiffeId != null) {
accessToken.setCustomClaim(IdToken.CLAIM_SPIFFE, spiffeId);
}
Comment on lines +2734 to +2737
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

For consistency and improved security, it would be best to verify that the SPIFFE ID from the subject token corresponds to the subject of the token, similar to the check in processIdTokenExchangeRequest. This prevents the propagation of a potentially mismatched SPIFFE ID if the subject token were ever compromised or misconfigured.

Suggested change
final String spiffeId = extractSpiffeIdFromToken(subjectToken);
if (spiffeId != null) {
accessToken.setCustomClaim(IdToken.CLAIM_SPIFFE, spiffeId);
}
final String spiffeId = extractSpiffeIdFromToken(subjectToken);
if (spiffeId != null) {
verifySpiffeIdMatchesAthenzPrincipal(spiffeId, subjectToken.getSubject(), caller, requestDomainName, principalDomain);
accessToken.setCustomClaim(IdToken.CLAIM_SPIFFE, spiffeId);
}


// if we have a certificate used for mTLS authentication then
// we're going to bind the certificate to the access token
// and the optional proxy principals if specified
Expand Down Expand Up @@ -2831,6 +2857,15 @@ AccessTokenResponse processAccessTokenDelegationRequest(ResourceContext ctx, Pri
caller, requestDomainName, principalDomain);
}

// now let's verify that our principal is authorized to carry
// out token delegation from source to target domain

final String resource = sourceDomainName + ":" + requestDomainName;
if (!authorizer.access(ZTSConsts.ZTS_ACTION_TOKEN_SOURCE_EXCHANGE, resource, principal, null)) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need this authorization check?

First, we've already verified at the beginnig of the method that princpal name is the same as the subject in the actor token.
Next, when validating the actor subject token in the token request parser, we've already verified that the subject token includes the mayAct claim and the value matches to the subject of the actor token. So the authorization has already been granted and verified so not sure we're adding yet another authorization check here?

throw forbiddenError("Principal not authorized for token delegation from source domain",
caller, requestDomainName, principalDomain);
}

// make sure our principal is authorized to request a token
// exchange for the given roles

Expand Down Expand Up @@ -2862,6 +2897,11 @@ AccessTokenResponse processAccessTokenDelegationRequest(ResourceContext ctx, Pri
accessToken.setIssuer(issuerResolver.getAccessTokenIssuer(ctx.request(), accessTokenRequest.isUseOpenIDIssuer()));
accessToken.setScope(new ArrayList<>(roles));

final String spiffeId = extractSpiffeIdFromToken(subjectToken);
if (spiffeId != null) {
accessToken.setCustomClaim(IdToken.CLAIM_SPIFFE, spiffeId);
}
Comment on lines +2900 to +2903
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

For consistency and improved security, it would be best to verify that the SPIFFE ID from the subject token corresponds to the subject of the token, similar to the check in processIdTokenExchangeRequest. This prevents the propagation of a potentially mismatched SPIFFE ID if the subject token were ever compromised or misconfigured.

Suggested change
final String spiffeId = extractSpiffeIdFromToken(subjectToken);
if (spiffeId != null) {
accessToken.setCustomClaim(IdToken.CLAIM_SPIFFE, spiffeId);
}
final String spiffeId = extractSpiffeIdFromToken(subjectToken);
if (spiffeId != null) {
verifySpiffeIdMatchesAthenzPrincipal(spiffeId, subjectToken.getSubject(), caller, requestDomainName, principalDomain);
accessToken.setCustomClaim(IdToken.CLAIM_SPIFFE, spiffeId);
}


// include the act claim in our response. we're going to use
// the act claim from the original token and then add our new
// actor on top of it
Expand Down Expand Up @@ -2995,14 +3035,13 @@ AccessTokenResponse processIdTokenExchangeRequest(ResourceContext ctx, Principal
List<String> idTokenGroups = processIdTokenRoles(subjectIdentity, tokenScope, domainName, true,
false, principalDomain, caller);

// make sure our principal is authorized to request a jag token
// make sure our principal is authorized to request an id token
// exchange for the given roles

Principal subjectPrincipal = createPrincipalForName(subjectIdentity);
for (String idTokenGroup : idTokenGroups) {
if (!authorizer.access(ZTSConsts.ZTS_ACTION_ID_TOKEN_EXCHANGE, idTokenGroup, subjectPrincipal, null)) {
if (!authorizer.access(ZTSConsts.ZTS_ACTION_ID_TOKEN_EXCHANGE, idTokenGroup, principal, null)) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why was this changed? it's an incorrect change.

We're checking if the principal in the subject token is authorized for the change not the principal who requested the exchange (which could be some proxy token exchange broker).

a few lines above, we verify that the principal in the request is authorized to request the token exchange on behalf of the subject principal and here we must verify that subject principal is authorized for the requested role

LOGGER.error("access check failure:({}, {}, {})", ZTSConsts.ZTS_ACTION_ID_TOKEN_EXCHANGE,
idTokenGroup, subjectPrincipal);
idTokenGroup, principal);
throw forbiddenError("Principal not authorized for token exchange for the requested role",
caller, domainName, principalDomain);
}
Expand All @@ -3020,6 +3059,14 @@ AccessTokenResponse processIdTokenExchangeRequest(ResourceContext ctx, Principal
idToken.setIssueTime(iat);
idToken.setAuthTime(iat);

final String spiffeId = extractSpiffeIdFromToken(subjectToken);
if (spiffeId != null) {
// If we're copying SPIFFE claim into the requested ID token, verify it maps
// to the authenticated principal to prevent mismatched SPIFFE identities.
verifySpiffeIdMatchesAthenzPrincipal(spiffeId, principalName, caller, domainName, principalDomain);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

The verifySpiffeIdMatchesAthenzPrincipal method is called with principalName, which is the identity of the caller making the token exchange request. However, the spiffeId is extracted from the subjectToken, and the new ID token is being issued for the subjectIdentity. The verification should be against the subjectIdentity to ensure the SPIFFE ID correctly corresponds to the subject of the token, preventing a caller from injecting a SPIFFE ID that doesn't belong to the token's subject.

The comment on line 3065 mentions "authenticated principal", which is the caller, but for security, the check should be against the identity for which the claim is being made.

Please note that with this change, the test testIdTokenExchangeSuccessWithSpiffeClaim in ZTSImplIDTokenTest.java will likely fail and will need to be updated. The spiffeId used in that test should correspond to the subject of the token being created.

Suggested change
verifySpiffeIdMatchesAthenzPrincipal(spiffeId, principalName, caller, domainName, principalDomain);
verifySpiffeIdMatchesAthenzPrincipal(spiffeId, subjectIdentity, caller, domainName, principalDomain);

idToken.setSpiffe(spiffeId);
}

// for user principals we're going to use the default 1 hour while for
// service principals 12 hours as the max timeout, unless the client
// is explicitly asking for something smaller.
Expand All @@ -3035,6 +3082,35 @@ AccessTokenResponse processIdTokenExchangeRequest(ResourceContext ctx, Principal
.setIssued_token_type(ZTSConsts.OAUTH_TOKEN_TYPE_ID).setExpires_in(tokenTimeout);
}

void verifySpiffeIdMatchesAthenzPrincipal(final String spiffeId, final String principalName,
final String caller, final String domainName, final String principalDomain) {

if (StringUtil.isEmpty(spiffeId) || StringUtil.isEmpty(principalName)) {
return;
}

// if the principal itself is a spiffe uri (jwt-svid spiffe-subject) then it must match exactly
if (principalName.startsWith(ZTSConsts.ZTS_CERT_SPIFFE_URI)) {
if (!spiffeId.equals(principalName)) {
throw requestError("SPIFFE ID does not match authenticated principal",
caller, domainName, principalDomain);
}
return;
}

final String principalSpiffeDomain = AthenzUtils.extractPrincipalDomainName(principalName);
final String principalSpiffeService = AthenzUtils.extractPrincipalServiceName(principalName);
if (principalSpiffeDomain == null || principalSpiffeService == null) {
throw requestError("Invalid principal name for SPIFFE validation: " + principalName,
caller, domainName, principalDomain);
}

if (!spiffeUriManager.validateServiceCertUri(spiffeId, principalSpiffeDomain, principalSpiffeService, null)) {
throw requestError("SPIFFE ID does not match authenticated principal",
caller, domainName, principalDomain);
}
}

AccessTokenResponse processJAGTokenIssueRequest(ResourceContext ctx, Principal principal,
AccessTokenRequest accessTokenRequest, final String principalDomain, final String caller) {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3945,6 +3945,13 @@ public void testTokenExchangeRequestedRolesScopeClaimAsString() {
private String createAccessToken(PrivateKey privateKey, final String keyId, final String subject,
final String audience, List<String> roles, final String mayActSubject, final String actSubject,
long expiryTime) {
return createAccessToken(privateKey, keyId, subject, audience, roles, mayActSubject, actSubject,
expiryTime, null);
}

private String createAccessToken(PrivateKey privateKey, final String keyId, final String subject,
final String audience, List<String> roles, final String mayActSubject, final String actSubject,
long expiryTime, final String spiffe) {
try {
AccessToken accessToken = new AccessToken();
accessToken.setVersion(1);
Expand All @@ -3965,6 +3972,9 @@ private String createAccessToken(PrivateKey privateKey, final String keyId, fina
if (actSubject != null) {
accessToken.setActEntry("sub", actSubject);
}
if (spiffe != null) {
accessToken.setCustomClaim("spiffe", spiffe);
}

ServerPrivateKey serverPrivateKey = new ServerPrivateKey(privateKey, keyId);

Expand Down Expand Up @@ -4078,6 +4088,9 @@ public void testProcessAccessTokenDelegationRequestSuccess() throws JOSEExceptio
SignedDomain targetDomain = createSignedDomain("targetdomain", "weather", "storage", true);
store.processSignedDomain(targetDomain, false);

// Add token source exchange policy
addTokenSourceExchangePolicy("sourcedomain", "targetdomain", "user_domain.proxy-user1");

// Add token target exchange policy
addTokenTargetExchangePolicy("targetdomain", "sourcedomain", "user_domain.proxy-user1", "writers");

Expand Down Expand Up @@ -4168,6 +4181,9 @@ public void testProcessAccessTokenExchangeDelegationRequestSuccess() throws JOSE
SignedDomain targetDomain = createSignedDomain("targetdomain", "weather", "storage", true);
store.processSignedDomain(targetDomain, false);

// Add token source exchange policy
addTokenSourceExchangePolicy("sourcedomain", "targetdomain", "user_domain.proxy-user1");

// Add token target exchange policy
addTokenTargetExchangePolicy("targetdomain", "sourcedomain", "user_domain.proxy-user1", "writers");

Expand Down Expand Up @@ -4245,6 +4261,100 @@ public void testProcessAccessTokenExchangeDelegationRequestSuccess() throws JOSE
cloudStore.close();
}

@Test
public void testProcessAccessTokenExchangeDelegationRequestSuccessWithSpiffeClaim() 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");

// Create source domain
SignedDomain sourceDomain = createSignedDomain("sourcedomain", "weather", "storage", true);
store.processSignedDomain(sourceDomain, false);

// Create target domain
SignedDomain targetDomain = createSignedDomain("targetdomain", "weather", "storage", true);
store.processSignedDomain(targetDomain, false);

// Add token source exchange policy
addTokenSourceExchangePolicy("sourcedomain", "targetdomain", "user_domain.proxy-user1");

// Add token target exchange policy
addTokenTargetExchangePolicy("targetdomain", "sourcedomain", "user_domain.proxy-user1", "writers");

// Load EC private key for creating tokens
final File ecPrivateKey = new File("./src/test/resources/unit_test_zts_private_ec.pem");
PrivateKey privateKey = Crypto.loadPrivateKey(ecPrivateKey);
KeyStore keyStore = getServerPublicKeyProvider(privateKey);

// Create subject token (AccessToken) with roles in source domain
long expiryTime = System.currentTimeMillis() / 1000 + 3600;
List<String> subjectRoles = List.of("writers");
final String spiffeId = "spiffe://athenz.io/ns/default/sa/sourcedomain.weather";
String subjectTokenStr = createAccessToken(privateKey, "0", "user_domain.user",
"sourcedomain", subjectRoles, "user_domain.proxy-user1", null, expiryTime, spiffeId);

// Create actor token (OAuth2Token)
String actorTokenStr = createActorToken(privateKey, "0", "user_domain.proxy-user1",
"targetdomain", expiryTime);

// Create principal for proxy-user1 who will request the token exchange
Principal principal = SimplePrincipal.create("user_domain", "proxy-user1",
"v=U1;d=user_domain;n=proxy-user1;s=signature", 0, null);
assertNotNull(principal);
ResourceContext context = createResourceContext(principal);
TokenConfigOptions tokenConfigOptions = createTokenConfigOptions(ztsImpl);
tokenConfigOptions.setOauth2Issuers(Set.of("https://athenz.io:4443/zts/v1"));
tokenConfigOptions.setPublicKeyProvider(keyStore);
ztsImpl.tokenConfigOptions = tokenConfigOptions;

final String requestBody = "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";

AccessTokenResponse response = ztsImpl.postAccessTokenRequest(context, requestBody);

assertNotNull(response);
assertNotNull(response.getAccess_token());
assertEquals(response.getToken_type(), "Bearer");
assertTrue(response.getExpires_in() > 0);
assertEquals(response.getScope(), "targetdomain:role.writers");

// Verify the access token
String accessTokenStr = response.getAccess_token();
ServerPrivateKey serverPrivateKey = getServerPrivateKey(ztsImpl, ztsImpl.keyAlgoForJsonWebObjects);
JWSVerifier verifier = JwtsHelper.getJWSVerifier(Crypto.extractPublicKey(serverPrivateKey.getKey()));

try {
SignedJWT signedJWT = SignedJWT.parse(accessTokenStr);
assertTrue(signedJWT.verify(verifier));
JWTClaimsSet claimSet = signedJWT.getJWTClaimsSet();

assertNotNull(claimSet);
assertNotNull(claimSet.getJWTID());
assertEquals(claimSet.getSubject(), "user_domain.user");
assertEquals(claimSet.getAudience().get(0), "targetdomain");
assertEquals(claimSet.getIssuer(), ztsImpl.ztsOAuthIssuer);
assertEquals(claimSet.getStringClaim("spiffe"), spiffeId);

List<String> scopes = claimSet.getStringListClaim("scp");
assertNotNull(scopes);
assertEquals(scopes.size(), 1);
assertEquals(scopes.get(0), "writers");
} catch (Exception ex) {
fail(ex.getMessage());
}

cloudStore.close();
}

@Test
public void testProcessAccessTokenExchangeDelegationRequestSuccessMultipleActors() throws JOSEException {
System.setProperty(FilePrivateKeyStore.ATHENZ_PROP_PRIVATE_KEY, "src/test/resources/unit_test_zts_at_private.pem");
Expand All @@ -4262,6 +4372,9 @@ public void testProcessAccessTokenExchangeDelegationRequestSuccessMultipleActors
SignedDomain targetDomain = createSignedDomain("targetdomain", "weather", "storage", true);
store.processSignedDomain(targetDomain, false);

// Add token source exchange policy
addTokenSourceExchangePolicy("sourcedomain", "targetdomain", "user_domain.proxy-user1");

// Add token target exchange policy
addTokenTargetExchangePolicy("targetdomain", "sourcedomain", "user_domain.proxy-user1", "writers");

Expand Down Expand Up @@ -4665,6 +4778,9 @@ public void testProcessAccessTokenDelegationRequestNotAuthorizedForExchange() {
SignedDomain targetDomain = createSignedDomain("targetdomain", "weather", "storage", true);
store.processSignedDomain(targetDomain, false);

// Add token source exchange policy
addTokenSourceExchangePolicy("sourcedomain", "targetdomain", "user_domain.proxy-user1");

// Don't add token target exchange policy - principal won't be authorized

final File ecPrivateKey = new File("./src/test/resources/unit_test_zts_private_ec.pem");
Expand Down Expand Up @@ -4725,6 +4841,9 @@ public void testProcessAccessTokenDelegationRequestMultipleRoles() throws JOSEEx
SignedDomain targetDomain = createSignedDomain("targetdomain", "weather", "storage", true);
store.processSignedDomain(targetDomain, false);

// Add token source exchange policy
addTokenSourceExchangePolicy("sourcedomain", "targetdomain", "user_domain.proxy-user1");

addTokenTargetExchangePolicy("targetdomain", "sourcedomain", "user_domain.proxy-user1", "writers");
addTokenTargetExchangePolicy("targetdomain", "sourcedomain", "user_domain.proxy-user1", "readers");

Expand Down Expand Up @@ -4810,6 +4929,9 @@ public void testProcessAccessTokenDelegationRequestWithExpiryTime() {
SignedDomain targetDomain = createSignedDomain("targetdomain", "weather", "storage", true);
store.processSignedDomain(targetDomain, false);

// Add token source exchange policy
addTokenSourceExchangePolicy("sourcedomain", "targetdomain", "user_domain.proxy-user1");

addTokenTargetExchangePolicy("targetdomain", "sourcedomain", "user_domain.proxy-user1", "writers");

final File ecPrivateKey = new File("./src/test/resources/unit_test_zts_private_ec.pem");
Expand Down Expand Up @@ -4869,6 +4991,9 @@ public void testProcessAccessTokenDelegationRequestWithOpenIDIssuer() throws JOS
SignedDomain targetDomain = createSignedDomain("targetdomain", "weather", "storage", true);
store.processSignedDomain(targetDomain, false);

// Add token source exchange policy
addTokenSourceExchangePolicy("sourcedomain", "targetdomain", "user_domain.proxy-user1");

addTokenTargetExchangePolicy("targetdomain", "sourcedomain", "user_domain.proxy-user1", "writers");

final File ecPrivateKey = new File("./src/test/resources/unit_test_zts_private_ec.pem");
Expand Down Expand Up @@ -4963,6 +5088,9 @@ public void testProcessAccessTokenDelegationRequestDefaultScope() {
SignedDomain targetDomain = createSignedDomain("targetdomain", "weather", "storage", true);
store.processSignedDomain(targetDomain, false);

// Add token source exchange policy
addTokenSourceExchangePolicy("sourcedomain", "targetdomain", "user_domain.proxy-user1");

addTokenTargetExchangePolicy("targetdomain", "sourcedomain", "user_domain.proxy-user1", "writers");

final File ecPrivateKey = new File("./src/test/resources/unit_test_zts_private_ec.pem");
Expand Down
Loading
Loading