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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
# Change Log
All notable changes to this project will be documented in this file.

## 4.1.0

- Expose the `sap_id_type` claim on `SapIdToken`
- New `SapIdToken#getIdType()` returning a typed `SapIdType` enum (`USER`, `APP`); resolves to `null` if the claim is absent or carries an unknown value
- New `TokenClaims.SAP_ID_TYPE` constant
- `DefaultIdTokenExtension#isTechnicalUser` now prefers the `sap_id_type` claim and falls back to the `sub == azp` heuristic for tokens issued before the claim was introduced

## 4.0.7

- Fix mTLS handshake regression in `SSLContextFactory`
Expand Down
54 changes: 54 additions & 0 deletions java-api/src/main/java/com/sap/cloud/security/token/SapIdType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* SPDX-FileCopyrightText: 2018-2023 SAP SE or an SAP affiliate company and Cloud Security Client Java contributors
* <p>
* SPDX-License-Identifier: Apache-2.0
*/
package com.sap.cloud.security.token;

import jakarta.annotation.Nullable;

/**
* Type of the principal an IAS-issued token belongs to. Mirrors the {@code sap_id_type} JWT claim
* issued by SAP Cloud Identity Service.
*/
public enum SapIdType {
/** Human end-user principal. */
USER("user"),
/** Technical / application principal. */
APP("app");

private final String claimValue;

SapIdType(String claimValue) {
this.claimValue = claimValue;
}

/**
* Returns the raw claim value used on the wire.
*
* @return the raw claim value as it appears in the JWT (e.g. {@code "user"} or {@code "app"}).
*/
public String claimValue() {
return claimValue;
}

/**
* Resolves a {@link SapIdType} from a raw {@code sap_id_type} claim value.
*
* @param value the claim value, may be {@code null}
* @return the matching {@link SapIdType}, or {@code null} if {@code value} is {@code null} or
* does not match a known type (forward-compatibility for future values)
*/
@Nullable
public static SapIdType fromClaimValue(@Nullable String value) {
if (value == null) {
return null;
}
for (SapIdType t : values()) {
if (t.claimValue.equals(value)) {
return t;
}
}
return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ private TokenClaims() {
public static final String SAP_GLOBAL_ZONE_ID = "zone_uuid"; // legacy claim
public static final String SAP_GLOBAL_APP_TID = "app_tid"; // tenant GUID

/**
* Indicates the type of the token principal. Issued by SAP Cloud Identity Service. Values
* are {@code "user"} for human end-users and {@code "app"} for technical/application
* principals. See {@link SapIdType} for the typed accessor.
*/
public static final String SAP_ID_TYPE = "sap_id_type";

public static final String GROUPS = "groups"; // scim groups
public static final String AUTHORIZATION_PARTY = "azp"; // Authorization party contains OAuth client identifier
public static final String CNF = "cnf"; // X509 certificate ("cnf" (confirmation)) claim
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
* <ul>
* <li>If cached ID Token is still valid, it is returned as is
* <li>If the current token is already an ID token, it is returned as-is.
* <li>If the token belongs to a technical user (where {@code sub == azp}), an exception is
* <li>If the token belongs to a technical user (claim {@code sap_id_type} = {@code "app"}, or
* {@code sub == azp} for pre-{@code sap_id_type} tokens), an exception is
* thrown.
* <li>If the token is an access token, it will be exchanged for an ID token using the configured
* IAS service credentials.
Expand Down Expand Up @@ -115,14 +116,21 @@ private boolean isAccessToken(Token token) {
/**
* Determines whether the token represents a technical user.
*
* <p>A token is considered to belong to a technical user if the {@code sub} (subject) claim
* equals the {@code azp} (authorized party / client ID) claim.
* <p>Prefers the {@code sap_id_type} claim ({@link SapIdType#APP}) when present. For tokens
* issued before the claim was introduced, falls back to comparing {@code sub} with
* {@code azp}.
*
* @param token the token to inspect
* @return {@code true} if the token belongs to a technical user
*/
private boolean isTechnicalUser(Token token) {

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.

Not sure how it is implemented in Node.js (probably like this) but I think it would be best to be conservative and demand "user" before attempting a token exchange. Theoretically, in this case, there could be new types introduced later for which the extension should also do the exchange but it does not do it because it requires "user". However, this is better than the alternative where the extension checks for "app" and attempts an exchange in all other cases, even when it should not (e.g. when new non-named-user values like "agent" are introduced).

String subject = token.getClaimAsString("sub");
if (token instanceof SapIdToken idToken) {
SapIdType idType = idToken.getIdType();
if (idType != null) {
return idType == SapIdType.APP;
}
}
String subject = token.getClaimAsString(TokenClaims.SUBJECT);
String azp = token.getClientId();
if (subject == null || azp == null || subject.isBlank() || azp.isBlank()) {
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,17 @@ public String getIssuer() {
public String getCnfX509Thumbprint() {
return getAttributeFromClaimAsString(TokenClaims.CNF, TokenClaims.CNF_X5T);
}

/**
* Returns the principal type carried by the {@code sap_id_type} claim. Issued by SAP Cloud
* Identity Service to disambiguate human end-users ({@link SapIdType#USER}) from
* technical/application principals ({@link SapIdType#APP}).
*
* @return the resolved {@link SapIdType}, or {@code null} if the claim is absent or carries
* an unknown value (e.g. a future principal type).
*/
@Nullable
public SapIdType getIdType() {
return SapIdType.fromClaimValue(getClaimAsString(TokenClaims.SAP_ID_TYPE));
}
Comment on lines +72 to +74

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.

The enum return type is the cleanest approach but it has the problem that it is not forward-compatible if new claim values are added, e.g. "agent" perhaps. You will need to do new releases and consumers will need to update before being able to read the claim correctly. With the old version, it will look like the claim is missing which might be misleading.

I would go for a String return value and export String constants for the currently known values, i.e. APP and USER that consumers can compare with today. It's less nice than comparing with an enum but it's honest about the fact that the claim could have more than these two values and allows programming already against that.

This is important when high-lever modules in the library stack like CAP use this feature: they cannot simply set a minimum version of the BTP sec libs for applications and begin to use newer enum values. Instead, they need to work also when older versions of the library are installed and where no enum value exists for new claim values.

}
Original file line number Diff line number Diff line change
Expand Up @@ -231,4 +231,53 @@ public void resolveIdToken_singleTenantToken_doesNotIncludeAppTid()
Map<String, String> params = paramCaptor.getValue();
assertThat(params.get("app_tid")).isNull();
}

@Test
public void resolveToken_sapIdTypeAppClaim_treatedAsTechnicalUser() {
SecurityContext.setToken(sapIdTokenWithPayload("{\"sap_id_type\":\"app\",\"sub\":\"user\",\"azp\":\"" + clientId + "\"}"));

assertThatThrownBy(() -> cut.resolveIdToken(null))
.isInstanceOf(IllegalArgumentException.class);
}

@Test
public void resolveToken_sapIdTypeUserClaim_overridesSubEqualsAzpHeuristic()
throws OAuth2ServiceException {
// sub == azp would have flagged this as technical under the legacy heuristic; the explicit
// sap_id_type=user claim takes precedence so the exchange proceeds.
SapIdToken userToken = sapIdTokenWithPayload(
"{\"sap_id_type\":\"user\",\"sub\":\"" + clientId + "\",\"azp\":\"" + clientId
+ "\",\"aud\":[\"audience\"],\"iss\":\"" + tokenUri + "\"}");
SecurityContext.setToken(userToken);

cut.resolveIdToken(null);

verify(tokenService)
.retrieveAccessTokenViaJwtBearerTokenGrant(
eq(completeTokenUri), any(), any(), any(), any(), eq(false));
}

private static SapIdToken sapIdTokenWithPayload(String payload) {
return new SapIdToken(new com.sap.cloud.security.xsuaa.jwt.DecodedJwt() {
@Override
public String getHeader() {
return "{}";
}

@Override
public String getPayload() {
return payload;
}

@Override
public String getSignature() {
return "";
}

@Override
public String getEncodedToken() {
return "encoded";
}
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import org.junit.jupiter.api.Test;

import com.sap.cloud.security.config.Service;
import com.sap.cloud.security.xsuaa.jwt.DecodedJwt;
import org.apache.commons.io.IOUtils;

import java.io.IOException;
Expand Down Expand Up @@ -62,4 +63,56 @@ public void getCnfThumbprint() {
public void getAppTid() {
assertThat(cut.getAppTid()).isEqualTo("the-app-tid");
}

@Test
public void getIdType_user() {
assertThat(tokenWithPayload("{\"sap_id_type\":\"user\"}").getIdType()).isEqualTo(SapIdType.USER);
}

@Test
public void getIdType_app() {
assertThat(tokenWithPayload("{\"sap_id_type\":\"app\"}").getIdType()).isEqualTo(SapIdType.APP);
}

@Test
public void getIdType_claimMissing_returnsNull() {
assertThat(cut.getIdType()).isNull();
}

@Test
public void getIdType_unknownClaimValue_returnsNull() {
assertThat(tokenWithPayload("{\"sap_id_type\":\"future-type\"}").getIdType()).isNull();
}

private static SapIdToken tokenWithPayload(String payloadJson) {
return new SapIdToken(new StubDecodedJwt(payloadJson));
}

private static final class StubDecodedJwt implements DecodedJwt {
private final String payload;

StubDecodedJwt(String payload) {
this.payload = payload;
}

@Override
public String getHeader() {
return "{}";
}

@Override
public String getPayload() {
return payload;
}

@Override
public String getSignature() {
return "";
}

@Override
public String getEncodedToken() {
return "";
}
}
}