Skip to content

Commit 679ca14

Browse files
Scicas 3580 ias token exchange (#1848)
* SCICAS-3580: - extended SecurityContext to hold attachments * SCICAS-3580: - extended IasTokenAuthenticator to call hooks after sucessfully validating a token * SCICAS-3580: - created a ContextExtension interface for after-validation hooks to extend the SecurityContext - implemented a IasToXsuaaExtension to swap a weak IAS Token to a strong IAS token and (if wanted) to a XSUAA token. Saves all tokens to the SecurityContext * SCICAS-3580: - reformatted code * SCICAS-3580: - removed the ContextExtension principle and added a new hybrid token authenticator instead that handles the switch from IAS to XSUAA token * getIdToken Exchange: - added a new SecurityContext extension that handles the token exchange from weak access token to strong ID Token - added logic to the SecurityContext to register a new Context Extension and to use this extension to get a new ID Token * getIdToken Exchange: - resolved MR comments - reformatted code * getIdToken Exchange: - added logic to only return ID Token if it doesnt expire within next 5 minutes * getIdToken Exchange: - added nullcheck * getIdToken Exchange: - minor changes * getIdToken Exchange: - minor changes / MR comments - added tests * getIdToken Exchange: - fixed javadoc header * getIdToken Exchange: - added a new placeholder variable for the initial token to work on and to keep it saved within the security context - changed the logic in the DefaultIdTokenExtension to work with the initial token instead of the real token as this token may vary in further topics if it gets exchanged for an XSUAA Token * getIdToken Exchange: - minor change in client id retrieval logic * getIdToken Exchange: - removed IAS logic and used the new getIDToken function to retrieve ID Token * SCICAS-3580: - PR comments * SCICAS-3580: - PR comments - reformatted code and applied happy path concept to remove if clause nesting - added javadoc * SCICAS-3580: - removed final from exception * SCICAS-3580: - pr comments * SCICAS-3580: - pr comments * SCICAS-3580: - pr comments - added tests
1 parent 9d16b8a commit 679ca14

File tree

9 files changed

+673
-90
lines changed

9 files changed

+673
-90
lines changed

java-api/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@
116116
<scope>test</scope>
117117
<version>${slf4j.api.version}</version>
118118
</dependency>
119-
</dependencies>
119+
</dependencies>
120120

121121
<build>
122122
<plugins>

java-api/src/main/java/com/sap/cloud/security/token/SecurityContext.java

Lines changed: 37 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,12 @@ public static Certificate getClientCertificate() {
4141
return certificateStorage.get();
4242
}
4343

44-
/**
45-
* Saves the certificate thread wide.
46-
*
47-
* @param certificate
48-
* certificate to be saved.
49-
*/
50-
public static void setClientCertificate(Certificate certificate) {
44+
/**
45+
* Saves the certificate thread wide.
46+
*
47+
* @param certificate certificate to be saved.
48+
*/
49+
public static void setClientCertificate(final Certificate certificate) {
5150
LOGGER.debug("Sets certificate to SecurityContext (thread-locally). {}",
5251
certificate);
5352
certificateStorage.set(certificate);
@@ -69,7 +68,7 @@ private static void clearCertificate() {
6968
*
7069
* @param token token to be saved.
7170
*/
72-
public static void setToken(Token token) {
71+
public static void setToken(final Token token) {
7372
LOGGER.debug(
7473
"Sets token of service {} to SecurityContext (thread-locally).",
7574
token != null ? token.getService() : "null");
@@ -78,6 +77,19 @@ public static void setToken(Token token) {
7877
idTokenStorage.remove();
7978
}
8079

80+
/**
81+
* Saves the token thread wide. Only used in special cases to overwrite only the token for
82+
* internal usage.
83+
*
84+
* @param token token to be saved.
85+
*/
86+
public static void overwriteToken(final Token token) {
87+
LOGGER.debug(
88+
"Sets token of service {} to SecurityContext (thread-locally).",
89+
token != null ? token.getService() : "null");
90+
tokenStorage.set(token);
91+
}
92+
8193
/**
8294
* Returns the token that is saved in thread wide storage.
8395
*
@@ -125,7 +137,7 @@ public static void registerIdTokenExtension(IdTokenExtension ext) {
125137
}
126138

127139
/**
128-
* Resolves an OpenID Connect ID token for the current user.
140+
* Experimental Resolves an OpenID Connect ID token for the current user.
129141
*
130142
* <p>Checks if a token is already present in the thread local storage and if it is still valid
131143
* (not expired or about to expire within 5 minutes). If a valid token is found, it is returned.
@@ -196,21 +208,22 @@ public static List<String> getServicePlans() {
196208
return servicePlanStorage.get();
197209
}
198210

199-
/**
200-
* Saves the Identity service broker plans in thread local storage.
201-
*
202-
* @param servicePlansHeader unprocessed Identity Service broker plan header value from response
203-
*/
204-
public static void setServicePlans(String servicePlansHeader) {
205-
// the header format contains a comma-separated list of quoted plan names, e.g. "plan1","plan \"two\"","plan3"
206-
String[] planParts = servicePlansHeader
207-
.trim()
208-
.split("\\s*,\\s*"); // split by <whitespaces>,<whitespaces>
209-
210-
// remove " around plan names
211-
List<String> plans = Arrays.stream(planParts)
212-
.map(plan -> plan.substring(1, plan.length() - 1))
213-
.collect(Collectors.toList());
211+
/**
212+
* Saves the Identity service broker plans in thread local storage.
213+
*
214+
* @param servicePlansHeader unprocessed Identity Service broker plan header value from response
215+
*/
216+
public static void setServicePlans(final String servicePlansHeader) {
217+
// the header format contains a comma-separated list of quoted plan names, e.g. "plan1","plan
218+
// \"two\"","plan3"
219+
final String[] planParts =
220+
servicePlansHeader.trim().split("\\s*,\\s*"); // split by <whitespaces>,<whitespaces>
221+
222+
// remove " around plan names
223+
final List<String> plans =
224+
Arrays.stream(planParts)
225+
.map(plan -> plan.substring(1, plan.length() - 1))
226+
.collect(Collectors.toList());
214227

215228
if (LOGGER.isDebugEnabled()) {
216229
LOGGER.debug("Sets Identity Service Plan {} to SecurityContext (thread-locally).",
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package com.sap.cloud.security.servlet;
2+
3+
import static com.sap.cloud.security.servlet.HybridTokenFactory.isXsuaaToken;
4+
import static com.sap.cloud.security.servlet.HybridTokenFactory.removeBearer;
5+
6+
import com.sap.cloud.security.config.OAuth2ServiceConfiguration;
7+
import com.sap.cloud.security.token.DefaultIdTokenExtension;
8+
import com.sap.cloud.security.token.SecurityContext;
9+
import com.sap.cloud.security.token.Token;
10+
import com.sap.cloud.security.xsuaa.client.DefaultOAuth2TokenService;
11+
import com.sap.cloud.security.xsuaa.client.OAuth2ServiceException;
12+
import com.sap.cloud.security.xsuaa.client.OAuth2TokenService;
13+
import com.sap.cloud.security.xsuaa.client.XsuaaTokenExchangeService;
14+
import com.sap.cloud.security.xsuaa.http.HttpHeaders;
15+
import com.sap.cloud.security.xsuaa.jwt.Base64JwtDecoder;
16+
import com.sap.cloud.security.xsuaa.jwt.DecodedJwt;
17+
import jakarta.servlet.ServletRequest;
18+
import jakarta.servlet.ServletResponse;
19+
import jakarta.servlet.http.HttpServletRequest;
20+
import jakarta.servlet.http.HttpServletResponse;
21+
import javax.annotation.Nonnull;
22+
import javax.annotation.Nullable;
23+
import org.apache.http.impl.client.CloseableHttpClient;
24+
25+
/**
26+
* Experimental Authenticates HTTP requests carrying either an IAS Access Token, ID token or an
27+
* XSUAA access token in the {@code Authorization} header.
28+
*
29+
* <p>If the token is already an XSUAA token, validation is delegated to {@link
30+
* XsuaaTokenAuthenticator}. Otherwise, the IAS token is validated via {@link
31+
* IasTokenAuthenticator}, and on success, exchanged for an XSUAA access token using the OAuth 2.0
32+
* JWT Bearer Token grant ({@code jwt_bearer}). The exchanged XSUAA token is then stored in the
33+
* {@link com.sap.cloud.security.token.SecurityContext} and used for subsequent authorization.
34+
*
35+
* <p>Requirements:
36+
*
37+
* <ul>
38+
* <li>XSUAA instance must support the {@code jwt_bearer} grant type.
39+
* <li>The IAS ID token must contain the {@code app_tid} (tenant) claim.
40+
* <li>Either client secret or certificate credentials must be configured for XSUAA.
41+
* </ul>
42+
*
43+
* <p>This authenticator is stateless and thread-safe. It should be invoked once per request,
44+
* typically from a servlet filter. Ensure the {@link com.sap.cloud.security.token.SecurityContext}
45+
* is cleared after each request to prevent token leakage between threads.
46+
*/
47+
public class HybridTokenAuthenticator extends AbstractTokenAuthenticator {
48+
49+
private final OAuth2ServiceConfiguration xsuaaConfig;
50+
private final OAuth2TokenService tokenService;
51+
private final IasTokenAuthenticator iasTokenAuthenticator = new IasTokenAuthenticator();
52+
private final XsuaaTokenAuthenticator xsuaaTokenAuthenticator = new XsuaaTokenAuthenticator();
53+
private final XsuaaTokenExchangeService exchangeService = new XsuaaTokenExchangeService();
54+
55+
public HybridTokenAuthenticator(
56+
@Nonnull final OAuth2ServiceConfiguration iasConfig,
57+
@Nonnull final CloseableHttpClient httpClient,
58+
@Nonnull final OAuth2ServiceConfiguration xsuaaConfig) {
59+
this.tokenService = new DefaultOAuth2TokenService(httpClient);
60+
this.xsuaaConfig = xsuaaConfig;
61+
SecurityContext.registerIdTokenExtension(new DefaultIdTokenExtension(tokenService, iasConfig));
62+
}
63+
64+
@Override
65+
public TokenAuthenticationResult validateRequest(
66+
final ServletRequest request, final ServletResponse response) {
67+
68+
if (!(request instanceof HttpServletRequest httpRequest
69+
&& response instanceof HttpServletResponse)) {
70+
return unauthenticated("Could not process request " + request);
71+
}
72+
final String authz = httpRequest.getHeader(HttpHeaders.AUTHORIZATION);
73+
if (!headerIsAvailable(authz)) {
74+
return unauthenticated("Authorization header is missing.");
75+
}
76+
DecodedJwt decodedJwt;
77+
try {
78+
decodedJwt = Base64JwtDecoder.getInstance().decode(removeBearer(authz));
79+
} catch (IllegalArgumentException e) {
80+
return unauthenticated("Unexpected error occurred: " + e.getMessage());
81+
}
82+
83+
// If token is already an XSUAA token, delegate to XSUAA authenticator
84+
if (isXsuaaToken(decodedJwt)) {
85+
return xsuaaTokenAuthenticator.validateRequest(httpRequest, response);
86+
}
87+
88+
// Otherwise, treat it as an IAS token
89+
final TokenAuthenticationResult iasResult =
90+
iasTokenAuthenticator.validateRequest(httpRequest, response);
91+
if (!iasResult.isAuthenticated()) {
92+
return iasResult;
93+
}
94+
try {
95+
final Token idToken = SecurityContext.getIdToken();
96+
if (idToken == null) {
97+
return unauthenticated("Missing IAS ID Token. Cannot exchange for XSUAA Token.");
98+
}
99+
final Token xsuaaToken = exchangeService.exchangeToXsuaa(idToken, xsuaaConfig, tokenService);
100+
SecurityContext.overwriteToken(xsuaaToken);
101+
return xsuaaTokenAuthenticator.authenticated(xsuaaToken);
102+
} catch (OAuth2ServiceException | IllegalArgumentException | IllegalStateException e) {
103+
return unauthenticated(
104+
"Unexpected error during exchange from ID token to XSUAA token:" + e.getMessage());
105+
}
106+
}
107+
108+
@Override
109+
protected OAuth2ServiceConfiguration getServiceConfiguration() {
110+
return xsuaaTokenAuthenticator.getServiceConfiguration();
111+
}
112+
113+
@Nullable
114+
@Override
115+
protected OAuth2ServiceConfiguration getOtherServiceConfiguration() {
116+
return xsuaaTokenAuthenticator.getOtherServiceConfiguration();
117+
}
118+
119+
@Override
120+
protected Token extractFromHeader(final String authorizationHeader) {
121+
return xsuaaTokenAuthenticator.extractFromHeader(authorizationHeader);
122+
}
123+
}

java-security/src/main/java/com/sap/cloud/security/servlet/HybridTokenFactory.java

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
*/
66
package com.sap.cloud.security.servlet;
77

8+
import static com.sap.cloud.security.token.TokenClaims.XSUAA.EXTERNAL_ATTRIBUTE;
9+
import static com.sap.cloud.security.token.TokenClaims.XSUAA.EXTERNAL_ATTRIBUTE_ENHANCER;
10+
811
import com.sap.cloud.security.config.Environments;
912
import com.sap.cloud.security.config.OAuth2ServiceConfiguration;
1013
import com.sap.cloud.security.config.ServiceConstants;
@@ -13,16 +16,12 @@
1316
import com.sap.cloud.security.xsuaa.Assertions;
1417
import com.sap.cloud.security.xsuaa.jwt.Base64JwtDecoder;
1518
import com.sap.cloud.security.xsuaa.jwt.DecodedJwt;
16-
import org.slf4j.Logger;
17-
import org.slf4j.LoggerFactory;
18-
19-
import javax.annotation.Nonnull;
2019
import java.util.Objects;
2120
import java.util.Optional;
2221
import java.util.regex.Pattern;
23-
24-
import static com.sap.cloud.security.token.TokenClaims.XSUAA.EXTERNAL_ATTRIBUTE;
25-
import static com.sap.cloud.security.token.TokenClaims.XSUAA.EXTERNAL_ATTRIBUTE_ENHANCER;
22+
import javax.annotation.Nonnull;
23+
import org.slf4j.Logger;
24+
import org.slf4j.LoggerFactory;
2625

2726
/**
2827
* Creates a {@link Token} instance. Supports Jwt tokens from IAS and XSUAA identity service. TokenFactory loads and
@@ -34,14 +33,16 @@ public class HybridTokenFactory implements TokenFactory {
3433
static Optional<String> xsAppId;
3534
static ScopeConverter xsScopeConverter;
3635

37-
/**
38-
* Determines whether the JWT token is issued by XSUAA or IAS identity service, and creates a Token for it.
39-
*
40-
* @param jwtToken
41-
* the encoded JWT token (access_token or id_token), e.g. from the Authorization Header.
42-
* @return the new token instance
43-
*/
44-
public Token create(String jwtToken) {
36+
/**
37+
* Determines whether the JWT token is issued by XSUAA or IAS identity service, and creates a
38+
* Token for it.
39+
*
40+
* @param jwtToken the encoded JWT token (access_token or id_token), e.g. from the Authorization
41+
* Header.
42+
* @return the new token instance
43+
*/
44+
@Override
45+
public Token create(String jwtToken) {
4546
try {
4647
Objects.requireNonNull(jwtToken, "Requires encoded jwtToken to create a Token instance.");
4748
DecodedJwt decodedJwt = Base64JwtDecoder.getInstance().decode(removeBearer(jwtToken));
@@ -90,22 +91,21 @@ private static Optional<String> getXsAppId() {
9091
return xsAppId;
9192
}
9293

93-
/**
94-
* Determines if the provided decoded jwt token is issued by the XSUAA identity service.
95-
*
96-
* @param decodedJwt
97-
* jwt to be checked
98-
* @return true if provided token is a XSUAA token
99-
*/
100-
private static boolean isXsuaaToken(DecodedJwt decodedJwt) {
94+
/**
95+
* Determines if the provided decoded jwt token is issued by the XSUAA identity service.
96+
*
97+
* @param decodedJwt jwt to be checked
98+
* @return true if provided token is a XSUAA token
99+
*/
100+
protected static boolean isXsuaaToken(DecodedJwt decodedJwt) {
101101
String jwtPayload = decodedJwt.getPayload().toLowerCase();
102102
return (jwtPayload.contains(EXTERNAL_ATTRIBUTE)
103103
&& jwtPayload.contains(EXTERNAL_ATTRIBUTE_ENHANCER)
104104
&& jwtPayload.contains("xsuaa"))
105105
|| jwtPayload.contains("\"zid\":\"uaa\",");
106106
}
107107

108-
private static String removeBearer(@Nonnull String jwtToken) {
108+
protected static String removeBearer(@Nonnull String jwtToken) {
109109
Assertions.assertHasText(jwtToken, "jwtToken must not be null / empty");
110110
Pattern bearerPattern = Pattern.compile("[B|b]earer ");
111111
return bearerPattern.matcher(jwtToken).replaceFirst("");

java-security/src/main/java/com/sap/cloud/security/servlet/IasTokenAuthenticator.java

Lines changed: 31 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -14,38 +14,39 @@
1414
import jakarta.servlet.ServletRequest;
1515
import jakarta.servlet.ServletResponse;
1616
import jakarta.servlet.http.HttpServletRequest;
17-
1817
import javax.annotation.Nullable;
1918

2019
public class IasTokenAuthenticator extends AbstractTokenAuthenticator {
2120

22-
@Override
23-
public Token extractFromHeader(String authorizationHeader) {
24-
return new SapIdToken(authorizationHeader);
25-
}
26-
27-
@Override
28-
public TokenAuthenticationResult validateRequest(ServletRequest request, ServletResponse response) {
29-
HttpServletRequest httpRequest = (HttpServletRequest) request;
30-
SecurityContext.setClientCertificate(X509Certificate
31-
.newCertificate(getClientCertificate(httpRequest)));
32-
return super.validateRequest(request, response);
33-
}
34-
35-
@Override
36-
protected OAuth2ServiceConfiguration getServiceConfiguration() {
37-
OAuth2ServiceConfiguration config = serviceConfiguration != null ? serviceConfiguration
38-
: Environments.getCurrent().getIasConfiguration();
39-
if (config == null) {
40-
throw new IllegalStateException("There must be a service configuration.");
41-
}
42-
return config;
43-
}
44-
45-
@Nullable
46-
@Override
47-
protected OAuth2ServiceConfiguration getOtherServiceConfiguration() {
48-
return null;
49-
}
50-
21+
@Override
22+
public Token extractFromHeader(final String authorizationHeader) {
23+
return new SapIdToken(authorizationHeader);
24+
}
25+
26+
@Override
27+
public TokenAuthenticationResult validateRequest(
28+
final ServletRequest request, final ServletResponse response) {
29+
final HttpServletRequest httpRequest = (HttpServletRequest) request;
30+
SecurityContext.setClientCertificate(
31+
X509Certificate.newCertificate(getClientCertificate(httpRequest)));
32+
return super.validateRequest(request, response);
33+
}
34+
35+
@Override
36+
protected OAuth2ServiceConfiguration getServiceConfiguration() {
37+
final OAuth2ServiceConfiguration config =
38+
serviceConfiguration != null
39+
? serviceConfiguration
40+
: Environments.getCurrent().getIasConfiguration();
41+
if (config == null) {
42+
throw new IllegalStateException("There must be a service configuration.");
43+
}
44+
return config;
45+
}
46+
47+
@Nullable
48+
@Override
49+
protected OAuth2ServiceConfiguration getOtherServiceConfiguration() {
50+
return null;
51+
}
5152
}

0 commit comments

Comments
 (0)