Skip to content

Commit 7dc8845

Browse files
committed
Implement the DpopAuthScheme + add testing
1 parent 8b5d0c8 commit 7dc8845

File tree

6 files changed

+310
-147
lines changed

6 files changed

+310
-147
lines changed

services/signin/src/main/java/software/amazon/awssdk/services/signin/auth/LoginCredentialsProvider.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,12 @@
2929
import software.amazon.awssdk.auth.credentials.AwsCredentials;
3030
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
3131
import software.amazon.awssdk.auth.credentials.AwsSessionCredentials;
32+
import software.amazon.awssdk.core.SdkPlugin;
3233
import software.amazon.awssdk.core.exception.SdkClientException;
3334
import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId;
3435
import software.amazon.awssdk.services.signin.SigninClient;
3536
import software.amazon.awssdk.services.signin.internal.AccessTokenManager;
37+
import software.amazon.awssdk.services.signin.internal.DpopAuthScheme;
3638
import software.amazon.awssdk.services.signin.internal.LoginAccessToken;
3739
import software.amazon.awssdk.services.signin.internal.LoginCacheDirectorySystemSetting;
3840
import software.amazon.awssdk.services.signin.internal.OnDiskTokenManager;
@@ -159,14 +161,15 @@ private RefreshResult<AwsCredentials> refreshFromSigninService(LoginAccessToken
159161
log.debug(() -> "Credentials are near expiration/expired, refreshing from Signin service.");
160162

161163
try {
164+
SdkPlugin dpopAuthPlugin = DpopAuthScheme.DpopAuthPlugin.create(tokenFromDisc.getDpopKey());
162165
CreateOAuth2TokenRequest refreshRequest =
163166
CreateOAuth2TokenRequest
164167
.builder()
165168
.tokenInput(t -> t
166169
.clientId(tokenFromDisc.getClientId())
167170
.refreshToken(tokenFromDisc.getRefreshToken())
168171
.grantType("refresh_token"))
169-
.dpopProof("TODO") // TODO: This will be implemented in a separate PR
172+
.overrideConfiguration(c -> c.addPlugin(dpopAuthPlugin))
170173
.build();
171174

172175
CreateOAuth2TokenResponse createTokenResponse = signinClient.createOAuth2Token(refreshRequest);

services/signin/src/main/java/software/amazon/awssdk/services/signin/internal/DpopAuthScheme.java

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,20 @@
1717

1818
import java.security.interfaces.ECPrivateKey;
1919
import java.security.interfaces.ECPublicKey;
20+
import java.time.Instant;
2021
import java.util.Collections;
2122
import java.util.List;
23+
import java.util.UUID;
2224
import java.util.concurrent.CompletableFuture;
2325
import software.amazon.awssdk.core.SdkPlugin;
26+
import software.amazon.awssdk.core.SdkRequest;
2427
import software.amazon.awssdk.core.SdkServiceClientConfiguration;
28+
import software.amazon.awssdk.http.SdkHttpRequest;
2529
import software.amazon.awssdk.http.auth.spi.scheme.AuthScheme;
2630
import software.amazon.awssdk.http.auth.spi.scheme.AuthSchemeOption;
2731
import software.amazon.awssdk.http.auth.spi.signer.AsyncSignRequest;
2832
import software.amazon.awssdk.http.auth.spi.signer.AsyncSignedRequest;
33+
import software.amazon.awssdk.http.auth.spi.signer.BaseSignRequest;
2934
import software.amazon.awssdk.http.auth.spi.signer.HttpSigner;
3035
import software.amazon.awssdk.http.auth.spi.signer.SignRequest;
3136
import software.amazon.awssdk.http.auth.spi.signer.SignedRequest;
@@ -38,6 +43,7 @@
3843
import software.amazon.awssdk.services.signin.auth.scheme.SigninAuthSchemeProvider;
3944
import software.amazon.awssdk.utils.Pair;
4045
import software.amazon.awssdk.utils.Validate;
46+
import software.amazon.awssdk.utils.http.SdkHttpUtils;
4147

4248
public class DpopAuthScheme implements AuthScheme<DpopAuthScheme.DpopIdentity> {
4349
public static final String SCHEME_NAME = "DPOP";
@@ -101,7 +107,7 @@ private static class DpopSigner implements HttpSigner<DpopIdentity> {
101107
@Override
102108
public SignedRequest sign(SignRequest<? extends DpopIdentity> request) {
103109
return SignedRequest.builder()
104-
.request(request.request())
110+
.request(doSign(request))
105111
.payload(request.payload().orElse(null))
106112
.build();
107113
}
@@ -110,10 +116,37 @@ public SignedRequest sign(SignRequest<? extends DpopIdentity> request) {
110116
public CompletableFuture<AsyncSignedRequest> signAsync(AsyncSignRequest<? extends DpopIdentity> request) {
111117
return CompletableFuture.completedFuture(
112118
AsyncSignedRequest.builder()
113-
.request(request.request())
119+
.request(doSign(request))
114120
.payload(request.payload().orElse(null))
115121
.build());
116122
}
123+
124+
/**
125+
* Using {@link BaseSignRequest}, sign the request with a {@link BaseSignRequest} and re-build it.
126+
*/
127+
private SdkHttpRequest doSign(BaseSignRequest<?, ? extends DpopIdentity> request) {
128+
return request.request().toBuilder()
129+
.putHeader(
130+
"DPoP",
131+
buildDpopHeader(request))
132+
.build();
133+
}
134+
135+
private String buildDpopHeader(BaseSignRequest<?, ? extends DpopIdentity> request) {
136+
SdkHttpRequest httpRequest = request.request();
137+
String endpoint = extractRequestEndpoint(httpRequest);
138+
return DpopHeaderGenerator.generateDPoPProofHeader(
139+
request.identity(), endpoint, httpRequest.method().name(),
140+
Instant.now().getEpochSecond(), UUID.randomUUID().toString());
141+
}
142+
143+
private static String extractRequestEndpoint(SdkHttpRequest httpRequest) {
144+
// using SdkHttpRequest.getUri() results in creating a URI which is slow and we don't need the query components
145+
// construct only the endpoint that we require for DPoP.
146+
String portString =
147+
SdkHttpUtils.isUsingStandardPort(httpRequest.protocol(), httpRequest.port()) ? "" : ":" + httpRequest.port();
148+
return httpRequest.protocol() + "://" + httpRequest.host() + portString + httpRequest.encodedPath();
149+
}
117150
}
118151

119152
private static class DpopIdentityProvider implements IdentityProvider<DpopIdentity> {

services/signin/src/main/java/software/amazon/awssdk/services/signin/internal/DpopHeaderGenerator.java

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,26 +57,25 @@ private DpopHeaderGenerator() {
5757
* <li><a href="https://datatracker.ietf.org/doc/html/rfc7517">RFC 7517 - JSON Web Key (JWK)</a></li>
5858
* </ul>
5959
*
60-
* @param pemContent - EC1 / RFC 5915 ASN.1 formated PEM contents
60+
* @param dpopIdentity - DpopIdentity containing ECPrivateKey and ECPublicKey
6161
* @param endpoint - The HTTP target URI (Section 7.1 of [RFC9110]) of the request to which the JWT is attached,
6262
* without query and fragment parts
6363
* @param httpMethod - the HTTP method of the request (eg: POST).
6464
* @param epochSeconds - creation time of the JWT in epoch seconds.
6565
* @param uuid - Unique identifier for the DPoP proof JWT - should be a UUID4 string.
6666
* @return DPoP header value
6767
*/
68-
public static String generateDPoPProofHeader(String pemContent, String endpoint, String httpMethod,
68+
public static String generateDPoPProofHeader(DpopAuthScheme.DpopIdentity dpopIdentity, String endpoint, String httpMethod,
6969
long epochSeconds, String uuid) {
70-
Validate.paramNotBlank(pemContent, "pemContent");
70+
Validate.paramNotNull(dpopIdentity, "dpopIdentity");
7171
Validate.paramNotBlank(endpoint, "endpoint");
7272
Validate.paramNotBlank(httpMethod, "httpMethod");
7373
Validate.paramNotBlank(uuid, "uuid");
7474

7575
try {
7676
// Load EC public and private key from PEM
77-
Pair<ECPrivateKey, ECPublicKey> keys = EcKeyLoader.loadSec1Pem(pemContent);
78-
ECPrivateKey privateKey = keys.left();
79-
ECPublicKey publicKey = keys.right();
77+
ECPrivateKey privateKey = dpopIdentity.getPrivateKey();
78+
ECPublicKey publicKey = dpopIdentity.getPublicKey();
8079

8180
// Build JSON strings (header, payload) with JsonGenerator
8281
byte[] headerJson = buildHeaderJson(publicKey);

services/signin/src/test/java/software/amazon/awssdk/services/signin/auth/LoginCredentialsProviderTest.java

Lines changed: 97 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import static org.junit.jupiter.api.Assertions.assertEquals;
1919
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
20+
import static org.junit.jupiter.api.Assertions.assertNotNull;
2021
import static org.junit.jupiter.api.Assertions.assertThrows;
2122
import static org.junit.jupiter.api.Assertions.assertTrue;
2223
import static org.mockito.ArgumentMatchers.any;
@@ -25,38 +26,70 @@
2526
import static org.mockito.Mockito.times;
2627
import static org.mockito.Mockito.verify;
2728
import static org.mockito.Mockito.when;
29+
import static software.amazon.awssdk.services.signin.auth.internal.DpopTestUtils.VALID_TEST_PEM;
30+
import static software.amazon.awssdk.services.signin.auth.internal.DpopTestUtils.getJwtPayloadFromEncodedDpopHeader;
31+
import static software.amazon.awssdk.services.signin.auth.internal.DpopTestUtils.verifySignature;
2832

33+
import java.io.ByteArrayInputStream;
34+
import java.net.URI;
35+
import java.nio.charset.StandardCharsets;
2936
import java.nio.file.Path;
3037
import java.time.Instant;
38+
import java.util.ArrayList;
39+
import java.util.List;
40+
import java.util.Map;
3141
import org.junit.jupiter.api.BeforeEach;
3242
import org.junit.jupiter.api.Test;
3343
import org.junit.jupiter.api.io.TempDir;
3444
import org.mockito.ArgumentCaptor;
3545
import software.amazon.awssdk.auth.credentials.AwsCredentials;
3646
import software.amazon.awssdk.auth.credentials.AwsSessionCredentials;
47+
import software.amazon.awssdk.auth.signer.AwsSignerExecutionAttribute;
48+
import software.amazon.awssdk.core.SdkRequest;
3749
import software.amazon.awssdk.core.exception.SdkClientException;
50+
import software.amazon.awssdk.core.interceptor.Context;
51+
import software.amazon.awssdk.core.interceptor.ExecutionAttributes;
52+
import software.amazon.awssdk.core.interceptor.ExecutionInterceptor;
3853
import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId;
54+
import software.amazon.awssdk.http.AbortableInputStream;
55+
import software.amazon.awssdk.http.HttpExecuteResponse;
56+
import software.amazon.awssdk.http.SdkHttpRequest;
57+
import software.amazon.awssdk.http.SdkHttpResponse;
58+
import software.amazon.awssdk.regions.Region;
3959
import software.amazon.awssdk.services.signin.SigninClient;
4060
import software.amazon.awssdk.services.signin.internal.AccessTokenManager;
4161
import software.amazon.awssdk.services.signin.internal.LoginAccessToken;
4262
import software.amazon.awssdk.services.signin.internal.OnDiskTokenManager;
4363
import software.amazon.awssdk.services.signin.model.CreateOAuth2TokenRequest;
4464
import software.amazon.awssdk.services.signin.model.CreateOAuth2TokenResponse;
4565
import software.amazon.awssdk.services.signin.model.SigninException;
66+
import software.amazon.awssdk.testutils.service.http.MockAsyncHttpClient;
67+
import software.amazon.awssdk.testutils.service.http.MockSyncHttpClient;
4668

4769
public class LoginCredentialsProviderTest {
4870
private static final String LOGIN_SESSION_ID = "loginSessionId";
4971

5072
private AccessTokenManager tokenManager;
5173
private SigninClient signinClient;
74+
private MockSyncHttpClient mockHttpClient;
75+
private CaptureRequestInterceptor captureRequestInterceptor;
5276
private LoginCredentialsProvider loginCredentialsProvider;
5377

5478
@TempDir
5579
Path tempDir;
5680

5781
@BeforeEach
5882
public void setup() {
59-
signinClient = mock(SigninClient.class);
83+
mockHttpClient = new MockSyncHttpClient();
84+
captureRequestInterceptor = new CaptureRequestInterceptor();
85+
signinClient = SigninClient
86+
.builder()
87+
.region(Region.US_EAST_1)
88+
.endpointOverride(URI.create("https://custom-signin-endpoint.com"))
89+
.httpClient(mockHttpClient)
90+
.overrideConfiguration(c -> c.addExecutionInterceptor(captureRequestInterceptor))
91+
.build();
92+
6093
tokenManager = OnDiskTokenManager.create(tempDir, LOGIN_SESSION_ID);
6194

6295
loginCredentialsProvider = LoginCredentialsProvider
@@ -89,7 +122,7 @@ public void resolveCredentials_whenCredentialsFresh_usesFromDisk() {
89122

90123
AwsCredentials resolveCredentials = loginCredentialsProvider.resolveCredentials();
91124

92-
verify(signinClient, never()).createOAuth2Token(any(CreateOAuth2TokenRequest.class));
125+
assertEquals(0, mockHttpClient.getRequests().size());
93126

94127
assertEquals(creds.accessKeyId(), resolveCredentials.accessKeyId());
95128
assertEquals(creds.secretAccessKey(), resolveCredentials.secretAccessKey());
@@ -104,19 +137,17 @@ public void resolveCredentials_whenCredentialsNearExpiration_refreshesAndUpdates
104137
AwsSessionCredentials creds = buildCredentials(Instant.now().plusSeconds(10));
105138
LoginAccessToken token = buildAccessToken(creds);
106139
tokenManager.storeToken(token);
107-
when(signinClient.createOAuth2Token(any(CreateOAuth2TokenRequest.class))).thenReturn(
108-
buildSuccessfulRefreshResponse()
109-
);
110-
AwsCredentials resolvedCredentials = loginCredentialsProvider.resolveCredentials();
140+
stubSuccessfulRefreshResponse();
111141

112-
ArgumentCaptor<CreateOAuth2TokenRequest> captor =
113-
ArgumentCaptor.forClass(CreateOAuth2TokenRequest.class);
142+
AwsCredentials resolvedCredentials = loginCredentialsProvider.resolveCredentials();
114143

115144
// verify the service was called with correct arguments
116-
verify(signinClient, times(1)).createOAuth2Token(captor.capture());
117-
assertEquals(token.getClientId(), captor.getValue().tokenInput().clientId());
118-
assertEquals(token.getRefreshToken(), captor.getValue().tokenInput().refreshToken());
119-
assertEquals("refresh_token", captor.getValue().tokenInput().grantType());
145+
assertEquals(1, captureRequestInterceptor.requests.size());
146+
assertInstanceOf(CreateOAuth2TokenRequest.class, captureRequestInterceptor.requests.get(0));
147+
CreateOAuth2TokenRequest request = (CreateOAuth2TokenRequest) captureRequestInterceptor.requests.get(0);
148+
assertEquals(token.getClientId(), request.tokenInput().clientId());
149+
assertEquals(token.getRefreshToken(), request.tokenInput().refreshToken());
150+
assertEquals("refresh_token", request.tokenInput().grantType());
120151
// TODO: Assert validity of DPoP header once implemented
121152

122153
// verify that returned credentials are updated
@@ -126,28 +157,34 @@ public void resolveCredentials_whenCredentialsNearExpiration_refreshesAndUpdates
126157
verifyTokenCacheUpdated();
127158
}
128159

129-
130-
131160
@Test
132-
public void resolveCredentials_whenCredentialsExpired_refreshesAndUpdatesCache() {
161+
public void resolveCredentials_whenCredentialsExpired_refreshesAndUpdatesCache() throws Exception {
133162
// within the stale time
134163
AwsSessionCredentials creds = buildCredentials(Instant.now().minusSeconds(600));
135164
LoginAccessToken token = buildAccessToken(creds);
136165
tokenManager.storeToken(token);
137-
when(signinClient.createOAuth2Token(any(CreateOAuth2TokenRequest.class))).thenReturn(
138-
buildSuccessfulRefreshResponse()
139-
);
140-
AwsCredentials resolvedCredentials = loginCredentialsProvider.resolveCredentials();
166+
stubSuccessfulRefreshResponse();
141167

142-
ArgumentCaptor<CreateOAuth2TokenRequest> captor =
143-
ArgumentCaptor.forClass(CreateOAuth2TokenRequest.class);
168+
AwsCredentials resolvedCredentials = loginCredentialsProvider.resolveCredentials();
144169

145170
// verify the service was called with correct arguments
146-
verify(signinClient, times(1)).createOAuth2Token(captor.capture());
147-
assertEquals(token.getClientId(), captor.getValue().tokenInput().clientId());
148-
assertEquals(token.getRefreshToken(), captor.getValue().tokenInput().refreshToken());
149-
assertEquals("refresh_token", captor.getValue().tokenInput().grantType());
150-
// TODO: Assert validity of DPoP header once implemented
171+
assertEquals(1, captureRequestInterceptor.requests.size());
172+
assertInstanceOf(CreateOAuth2TokenRequest.class, captureRequestInterceptor.requests.get(0));
173+
CreateOAuth2TokenRequest request = (CreateOAuth2TokenRequest) captureRequestInterceptor.requests.get(0);
174+
assertEquals(token.getClientId(), request.tokenInput().clientId());
175+
assertEquals(token.getRefreshToken(), request.tokenInput().refreshToken());
176+
assertEquals("refresh_token", request.tokenInput().grantType());
177+
178+
// verify the request is correctly signed with DPoP header
179+
List<String> dpopHeader = captureRequestInterceptor.httpRequests.get(0).headers().get("DPoP");
180+
assertNotNull(dpopHeader);
181+
assertEquals(1, dpopHeader.size());
182+
assertTrue(verifySignature(dpopHeader.get(0)));
183+
184+
Map<String, Object> payload = getJwtPayloadFromEncodedDpopHeader(dpopHeader.get(0));
185+
assertEquals("POST", payload.get("htm"));
186+
assertEquals("https://custom-signin-endpoint.com/v1/token", payload.get("htu"));
187+
151188

152189
// verify that returned credentials are updated
153190
verifyResolvedCredentialsAreUpdated(resolvedCredentials);
@@ -162,7 +199,12 @@ public void resolveCredentials_whenCredentialsExpired_serviceCallFails_raisesExc
162199
AwsSessionCredentials creds = buildCredentials(Instant.now().minusSeconds(60));
163200
LoginAccessToken token = buildAccessToken(creds);
164201
tokenManager.storeToken(token);
165-
when(signinClient.createOAuth2Token(any(CreateOAuth2TokenRequest.class))).thenThrow(SigninException.class);
202+
mockHttpClient.stubNextResponse(
203+
HttpExecuteResponse
204+
.builder()
205+
.response(SdkHttpResponse.builder().statusCode(500).build())
206+
.build()
207+
);
166208
assertThrows(SdkClientException.class, () -> loginCredentialsProvider.resolveCredentials());
167209
}
168210

@@ -188,22 +230,23 @@ private void verifyTokenCacheUpdated() {
188230
assertEquals("new-refresh-token", updatedToken.getRefreshToken());
189231
}
190232

191-
private static CreateOAuth2TokenResponse buildSuccessfulRefreshResponse() {
192-
return CreateOAuth2TokenResponse
193-
.builder()
194-
.tokenOutput(
195-
t ->
196-
t
197-
.expiresIn(600)
198-
.refreshToken("new-refresh-token")
199-
.accessToken(
200-
c ->
201-
c
202-
.accessKeyId("new-akid")
203-
.secretAccessKey("new-skid")
204-
.sessionToken("new-session-token"))
205-
)
206-
.build();
233+
private void stubSuccessfulRefreshResponse() {
234+
String jsonBody =
235+
"{\"accessToken\":"
236+
+ "{\"accessKeyId\":\"new-akid\","
237+
+ "\"secretAccessKey\":\"new-skid\","
238+
+ "\"sessionToken\":\"new-session-token\"},"
239+
+ "\"tokenType\":\"aws_sigv4\","
240+
+ "\"expiresIn\":600,"
241+
+ "\"refreshToken\":\"new-refresh-token\"}";
242+
243+
mockHttpClient.stubNextResponse(
244+
HttpExecuteResponse
245+
.builder()
246+
.response(SdkHttpResponse.builder().statusCode(200).build())
247+
.responseBody(AbortableInputStream.create(new ByteArrayInputStream(jsonBody.getBytes(StandardCharsets.UTF_8))))
248+
.build()
249+
);
207250
}
208251

209252
private AwsSessionCredentials buildCredentials(Instant expirationTime) {
@@ -220,12 +263,22 @@ private LoginAccessToken buildAccessToken(AwsSessionCredentials credentials) {
220263
return LoginAccessToken.builder()
221264
.accessToken(credentials)
222265
.clientId("client-123")
223-
.dpopKey("dpop-key")
266+
.dpopKey(VALID_TEST_PEM)
224267
.refreshToken("refresh-token")
225268
.tokenType("aws_sigv4")
226269
.identityToken("id-token")
227270
.build();
228271
}
229272

273+
private static class CaptureRequestInterceptor implements ExecutionInterceptor {
230274

275+
private List<SdkHttpRequest> httpRequests = new ArrayList<>();
276+
private List<SdkRequest> requests = new ArrayList<>();
277+
278+
@Override
279+
public void beforeTransmission(Context.BeforeTransmission context, ExecutionAttributes executionAttributes) {
280+
this.httpRequests.add(context.httpRequest());
281+
this.requests.add(context.request());
282+
}
283+
}
231284
}

0 commit comments

Comments
 (0)