Skip to content

Commit 19092ab

Browse files
chenkinsdkocher
andauthored
Use token exchange standard v2 (#123)
Co-authored-by: David Kocher <[email protected]>
1 parent 05189ae commit 19092ab

File tree

11 files changed

+199
-313
lines changed

11 files changed

+199
-313
lines changed

hub/src/main/java/cloud/katta/protocols/hub/serializer/HubConfigDtoDeserializer.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import com.dd.plist.NSDictionary;
1414

1515
import static ch.cyberduck.core.Profile.*;
16-
import static cloud.katta.protocols.s3.S3AssumeRoleProtocol.OAUTH_TOKENEXCHANGE_AUDIENCE;
16+
import static cloud.katta.protocols.s3.S3AssumeRoleProtocol.OAUTH_TOKENEXCHANGE_CLIENT_ID;
1717

1818
public class HubConfigDtoDeserializer extends ProxyDeserializer<NSDictionary> {
1919

@@ -34,7 +34,7 @@ public <L> List<L> listForKey(final String key) {
3434
case PROPERTIES_KEY:
3535
final List<String> properties = new ArrayList<>(super.listForKey(key));
3636
if(dto.getKeycloakClientIdCryptomatorVaults() != null) {
37-
properties.add(String.format("%s=%s", OAUTH_TOKENEXCHANGE_AUDIENCE, dto.getKeycloakClientIdCryptomatorVaults()));
37+
properties.add(String.format("%s=%s", OAUTH_TOKENEXCHANGE_CLIENT_ID, dto.getKeycloakClientIdCryptomatorVaults()));
3838
}
3939
return (List<L>) properties;
4040
case SCOPES_KEY:

hub/src/main/java/cloud/katta/protocols/hub/serializer/StorageProfileDtoWrapperDeserializer.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,14 @@
66

77
import ch.cyberduck.core.serializer.Deserializer;
88

9-
import cloud.katta.client.model.Protocol;
10-
119
import org.apache.logging.log4j.LogManager;
1210
import org.apache.logging.log4j.Logger;
1311

1412
import java.util.ArrayList;
1513
import java.util.Arrays;
1614
import java.util.List;
1715

16+
import cloud.katta.client.model.Protocol;
1817
import cloud.katta.model.StorageProfileDtoWrapper;
1918
import cloud.katta.protocols.s3.S3AssumeRoleProtocol;
2019
import com.dd.plist.NSDictionary;
@@ -57,6 +56,7 @@ public <L> List<L> listForKey(final String key) {
5756
if(dto.getStsDurationSeconds() != null) {
5857
properties.add(String.format("%s=%s", S3AssumeRoleProtocol.S3_ASSUMEROLE_DURATIONSECONDS, dto.getStsDurationSeconds().toString()));
5958
}
59+
properties.add(String.format("%s=%s", S3AssumeRoleProtocol.OAUTH_TOKENEXCHANGE_CLIENT_SECRET, ""));
6060
}
6161
log.debug("Return properties {} from {}", properties, dto);
6262
return (List<L>) properties;

hub/src/main/java/cloud/katta/protocols/s3/S3AssumeRoleProtocol.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ public class S3AssumeRoleProtocol extends S3Protocol {
1515

1616
// Token exchange
1717
public static final String OAUTH_TOKENEXCHANGE = "oauth.tokenexchange";
18-
public static final String OAUTH_TOKENEXCHANGE_AUDIENCE = "oauth.tokenexchange.audience";
18+
public static final String OAUTH_TOKENEXCHANGE_CLIENT_ID = "oauth.tokenexchange.client_id";
19+
public static final String OAUTH_TOKENEXCHANGE_CLIENT_SECRET = "oauth.tokenexchange.audience.client_secret";
1920
public static final String OAUTH_TOKENEXCHANGE_ADDITIONAL_SCOPES = "oauth.tokenexchange.additional_scopes";
2021

2122
// STS assume role with web identity from Cyberduck core (AWS + MinIO)

hub/src/main/java/cloud/katta/protocols/s3/TokenExchangeRequestInterceptor.java

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@
44

55
package cloud.katta.protocols.s3;
66

7+
import ch.cyberduck.core.Credentials;
78
import ch.cyberduck.core.DefaultIOExceptionMappingService;
89
import ch.cyberduck.core.Host;
910
import ch.cyberduck.core.LoginCallback;
1011
import ch.cyberduck.core.OAuthTokens;
1112
import ch.cyberduck.core.exception.BackgroundException;
1213
import ch.cyberduck.core.exception.LoginCanceledException;
14+
import ch.cyberduck.core.exception.LoginFailureException;
1315
import ch.cyberduck.core.http.DefaultHttpResponseExceptionMappingService;
1416
import ch.cyberduck.core.oauth.OAuth2RequestInterceptor;
1517
import ch.cyberduck.core.oauth.OAuthExceptionMappingService;
@@ -24,7 +26,11 @@
2426
import java.io.IOException;
2527
import java.util.ArrayList;
2628
import java.util.Arrays;
29+
import java.util.List;
2730

31+
import com.auth0.jwt.JWT;
32+
import com.auth0.jwt.exceptions.JWTDecodeException;
33+
import com.auth0.jwt.interfaces.DecodedJWT;
2834
import com.google.api.client.auth.oauth2.TokenRequest;
2935
import com.google.api.client.auth.oauth2.TokenResponse;
3036
import com.google.api.client.auth.oauth2.TokenResponseException;
@@ -33,17 +39,24 @@
3339
import com.google.api.client.http.apache.v2.ApacheHttpTransport;
3440
import com.google.api.client.json.gson.GsonFactory;
3541

42+
import static cloud.katta.protocols.s3.S3AssumeRoleProtocol.OAUTH_TOKENEXCHANGE;
43+
3644
/**
37-
* Exchange OIDC token to scoped token using OAuth 2.0 Token Exchange
45+
* Exchange OIDC token to scoped token using OAuth 2.0 Token Exchange. Used for S3-STS in Katta.
3846
*/
3947
public class TokenExchangeRequestInterceptor extends OAuth2RequestInterceptor {
4048
private static final Logger log = LogManager.getLogger(TokenExchangeRequestInterceptor.class);
4149

4250
// https://datatracker.ietf.org/doc/html/rfc8693#name-request
4351
public static final String OAUTH_GRANT_TYPE_TOKEN_EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange";
4452
public static final String OAUTH_GRANT_TYPE_TOKEN_EXCHANGE_CLIENT_ID = "client_id";
45-
public static final String OAUTH_GRANT_TYPE_TOKEN_EXCHANGE_AUDIENCE = "audience";
53+
public static final String OAUTH_GRANT_TYPE_TOKEN_EXCHANGE_CLIENT_SECRET = "client_secret";
4654
public static final String OAUTH_GRANT_TYPE_TOKEN_EXCHANGE_SUBJECT_TOKEN = "subject_token";
55+
public static final String OAUTH_GRANT_TYPE_TOKEN_EXCHANGE_SUBJECT_TOKEN_TYPE = "subject_token_type";
56+
public static final String OAUTH_TOKEN_TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token";
57+
// https://openid.net/specs/openid-connect-core-1_0.html
58+
public static final String OIDC_AUTHORIZED_PARTY = "azp";
59+
4760

4861
private final Host bookmark;
4962
private final HttpClient client;
@@ -69,8 +82,7 @@ public OAuthTokens refresh(final OAuthTokens previous) throws BackgroundExceptio
6982
*
7083
* @param previous Input tokens retrieved to exchange at the token endpoint
7184
* @return New tokens
72-
*
73-
* @see S3AssumeRoleProtocol#OAUTH_TOKENEXCHANGE_AUDIENCE
85+
* @see S3AssumeRoleProtocol#OAUTH_TOKENEXCHANGE_CLIENT_ID
7486
* @see S3AssumeRoleProtocol#OAUTH_TOKENEXCHANGE_ADDITIONAL_SCOPES
7587
*/
7688
public OAuthTokens exchange(final OAuthTokens previous) throws BackgroundException {
@@ -83,10 +95,12 @@ public OAuthTokens exchange(final OAuthTokens previous) throws BackgroundExcepti
8395
);
8496
request.set(OAUTH_GRANT_TYPE_TOKEN_EXCHANGE_CLIENT_ID, bookmark.getProtocol().getOAuthClientId());
8597
final PreferencesReader preferences = new HostPreferences(bookmark);
86-
if(!StringUtils.isEmpty(preferences.getProperty(S3AssumeRoleProtocol.OAUTH_TOKENEXCHANGE_AUDIENCE))) {
87-
request.set(OAUTH_GRANT_TYPE_TOKEN_EXCHANGE_AUDIENCE, preferences.getProperty(S3AssumeRoleProtocol.OAUTH_TOKENEXCHANGE_AUDIENCE));
98+
if(!StringUtils.isEmpty(preferences.getProperty(S3AssumeRoleProtocol.OAUTH_TOKENEXCHANGE_CLIENT_ID))) {
99+
request.set(OAUTH_GRANT_TYPE_TOKEN_EXCHANGE_CLIENT_ID, preferences.getProperty(S3AssumeRoleProtocol.OAUTH_TOKENEXCHANGE_CLIENT_ID));
100+
request.set(OAUTH_GRANT_TYPE_TOKEN_EXCHANGE_CLIENT_SECRET, preferences.getProperty(S3AssumeRoleProtocol.OAUTH_TOKENEXCHANGE_CLIENT_SECRET));
88101
}
89102
request.set(OAUTH_GRANT_TYPE_TOKEN_EXCHANGE_SUBJECT_TOKEN, previous.getAccessToken());
103+
request.set(OAUTH_GRANT_TYPE_TOKEN_EXCHANGE_SUBJECT_TOKEN_TYPE, OAUTH_TOKEN_TYPE_ACCESS_TOKEN);
90104
final ArrayList<String> scopes = new ArrayList<>(bookmark.getProtocol().getOAuthScopes());
91105
if(!StringUtils.isEmpty(preferences.getProperty(S3AssumeRoleProtocol.OAUTH_TOKENEXCHANGE_ADDITIONAL_SCOPES))) {
92106
scopes.addAll(Arrays.asList(preferences.getProperty(S3AssumeRoleProtocol.OAUTH_TOKENEXCHANGE_ADDITIONAL_SCOPES).split(" ")));
@@ -113,4 +127,34 @@ public OAuthTokens exchange(final OAuthTokens previous) throws BackgroundExcepti
113127
throw new DefaultIOExceptionMappingService().map(e);
114128
}
115129
}
130+
131+
@Override
132+
public Credentials validate() throws BackgroundException {
133+
final Credentials credentials = super.validate();
134+
final OAuthTokens tokens = credentials.getOauth();
135+
final String accessToken = tokens.getAccessToken();
136+
final PreferencesReader preferences = new HostPreferences(bookmark);
137+
final String tokenExchangeClientId = preferences.getProperty(S3AssumeRoleProtocol.OAUTH_TOKENEXCHANGE_CLIENT_ID);
138+
if(StringUtils.isEmpty(tokenExchangeClientId)) {
139+
log.warn("Found {} empty, although {} is set to {} - misconfiguration?", S3AssumeRoleProtocol.OAUTH_TOKENEXCHANGE_CLIENT_ID, OAUTH_TOKENEXCHANGE, preferences.getBoolean(OAUTH_TOKENEXCHANGE));
140+
return credentials;
141+
}
142+
try {
143+
final DecodedJWT jwt = JWT.decode(accessToken);
144+
145+
final List<String> auds = jwt.getAudience();
146+
final String azp = jwt.getClaim(OIDC_AUTHORIZED_PARTY).asString();
147+
148+
final boolean audNotUnique = 1 != auds.size(); // either multiple audiences or none
149+
// do exchange if aud is not unique or azp is not equal to aud
150+
if(audNotUnique || !auds.get(0).equals(azp)) {
151+
log.debug("None or multiple audiences found {} or audience differs from azp {}, triggering token-exchange.", Arrays.toString(auds.toArray()), azp);
152+
return credentials.withOauth(this.exchange(tokens));
153+
}
154+
}
155+
catch(JWTDecodeException e) {
156+
throw new LoginFailureException("Invalid JWT or JSON format in authentication token", e);
157+
}
158+
return credentials;
159+
}
116160
}

hub/src/test/java/cloud/katta/core/HubSynchronizeTest.java

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ class HubSynchronizeTest {
2727
@TestInstance(PER_CLASS)
2828
public class Local extends AbstractHubSynchronizeTest {
2929
private Stream<Arguments> arguments() {
30-
return Stream.of(LOCAL_MINIO_STATIC, LOCAL_MINIO_STS);
30+
return Stream.of(
31+
LOCAL_MINIO_STATIC,
32+
LOCAL_MINIO_STS);
3133
}
3234
}
3335

@@ -56,7 +58,12 @@ private Stream<Arguments> arguments() {
5658
@TestInstance(PER_CLASS)
5759
public class Hybrid extends AbstractHubSynchronizeTest {
5860
private Stream<Arguments> arguments() {
59-
return Stream.of(HYBRID_MINIO_STATIC, HYBRID_MINIO_STS, HYBRID_AWS_STATIC, HYBRID_AWS_STS);
61+
return Stream.of(
62+
HYBRID_MINIO_STATIC,
63+
HYBRID_MINIO_STS ,
64+
HYBRID_AWS_STATIC,
65+
HYBRID_AWS_STS
66+
);
6067
}
6168
}
6269

hub/src/test/resources/.env

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ MINIO_HOSTNAME=localhost
1212
MINIO_PORT=9000
1313
MINIO_CONSOLE_PORT=9001
1414

15-
KATTA_KEYCLOAK_IMAGE=ghcr.io/shift7-ch/keycloak:26.1.5
15+
KATTA_KEYCLOAK_IMAGE=ghcr.io/cryptomator/keycloak:26.2.2
1616
KEYCLOAK_HOSTNAME=localhost
1717
KEYCLOAK_HTTP_PORT=8180
1818
KEYCLOAK_HTTPS_PORT=8443

hub/src/test/resources/.local.env

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ MINIO_HOSTNAME=localhost
1212
MINIO_PORT=9100
1313
MINIO_CONSOLE_PORT=9101
1414

15-
KATTA_KEYCLOAK_IMAGE=ghcr.io/shift7-ch/keycloak:26.1.5
15+
KATTA_KEYCLOAK_IMAGE=ghcr.io/cryptomator/keycloak:26.2.2
1616
KEYCLOAK_HOSTNAME=localhost
1717
KEYCLOAK_HTTP_PORT=8380
1818
KEYCLOAK_HTTPS_PORT=8443

hub/src/test/resources/docker-compose-minio-localhost-hub.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,11 @@ services:
1717
KC_HTTPS_CERTIFICATE_FILE: /opt/keycloak/dev/certs/keycloak-server.crt.pem
1818
KC_HTTPS_CERTIFICATE_KEY_FILE: /opt/keycloak/dev/certs/keycloak-server.key.pem
1919
volumes:
20-
# renamed because of https://github.com/keycloak/keycloak/issues/37569 for Keycloak 26.1.5
20+
# renamed because of https://github.com/keycloak/keycloak/issues/37569
2121
- ./keycloak/dev-realm.json:/opt/keycloak/data/import/keycloak.json
2222
- ./certs:/opt/keycloak/dev/certs
2323
# hub to Keycloak communication inside Docker network (http://keycloak:8180) goes to the container internal port! Therefore, we need to start keycloak with the same port `--http-port 8180`
24-
command: start-dev --import-realm --db=dev-mem --health-enabled=true --hostname ${KEYCLOAK_HOSTNAME} --http-port ${KEYCLOAK_HTTP_PORT} --features=token-exchange,admin-fine-grained-authz
24+
command: start-dev --import-realm --db=dev-mem --health-enabled=true --hostname ${KEYCLOAK_HOSTNAME} --http-port ${KEYCLOAK_HTTP_PORT}
2525
healthcheck:
2626
test: [ "CMD", "bash", "-c", "curl -v --fail http://127.0.0.1:${KEYCLOAK_HTTP_PORT}/realms/cryptomator/.well-known/openid-configuration" ]
2727
interval: 5s

0 commit comments

Comments
 (0)