Skip to content

Commit 5bf606b

Browse files
authored
feat: Add PKCE to 3LO exchange. (#1146)
* feat: Add PKCE to 3LO exchange.
1 parent 9db93eb commit 5bf606b

File tree

5 files changed

+295
-2
lines changed

5 files changed

+295
-2
lines changed
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/*
2+
* Copyright 2023, Google Inc. All rights reserved.
3+
*
4+
* Redistribution and use in source and binary forms, with or without
5+
* modification, are permitted provided that the following conditions are
6+
* met:
7+
*
8+
* * Redistributions of source code must retain the above copyright
9+
* notice, this list of conditions and the following disclaimer.
10+
* * Redistributions in binary form must reproduce the above
11+
* copyright notice, this list of conditions and the following disclaimer
12+
* in the documentation and/or other materials provided with the
13+
* distribution.
14+
*
15+
* * Neither the name of Google Inc. nor the names of its
16+
* contributors may be used to endorse or promote products derived from
17+
* this software without specific prior written permission.
18+
*
19+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
20+
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
21+
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
22+
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
23+
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
24+
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
25+
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
26+
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
27+
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
28+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29+
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30+
*/
31+
32+
package com.google.auth.oauth2;
33+
34+
import java.security.MessageDigest;
35+
import java.security.NoSuchAlgorithmException;
36+
import java.security.SecureRandom;
37+
import java.util.Base64;
38+
39+
public class DefaultPKCEProvider implements PKCEProvider {
40+
private String codeVerifier;
41+
private CodeChallenge codeChallenge;
42+
private static final int MAX_CODE_VERIFIER_LENGTH = 127;
43+
44+
private class CodeChallenge {
45+
private String codeChallenge;
46+
private String codeChallengeMethod;
47+
48+
CodeChallenge(String codeVerifier) {
49+
try {
50+
byte[] bytes = codeVerifier.getBytes();
51+
MessageDigest md = MessageDigest.getInstance("SHA-256");
52+
md.update(bytes);
53+
54+
byte[] digest = md.digest();
55+
56+
this.codeChallenge = Base64.getUrlEncoder().encodeToString(digest);
57+
this.codeChallengeMethod = "S256";
58+
} catch (NoSuchAlgorithmException e) {
59+
this.codeChallenge = codeVerifier;
60+
this.codeChallengeMethod = "plain";
61+
}
62+
}
63+
64+
public String getCodeChallenge() {
65+
return codeChallenge;
66+
}
67+
68+
public String getCodeChallengeMethod() {
69+
return codeChallengeMethod;
70+
}
71+
}
72+
73+
private String createCodeVerifier() {
74+
SecureRandom sr = new SecureRandom();
75+
byte[] code = new byte[MAX_CODE_VERIFIER_LENGTH];
76+
sr.nextBytes(code);
77+
return Base64.getUrlEncoder().encodeToString(code);
78+
}
79+
80+
private CodeChallenge createCodeChallenge(String codeVerifier) {
81+
return new DefaultPKCEProvider.CodeChallenge(codeVerifier);
82+
}
83+
84+
public DefaultPKCEProvider() {
85+
this.codeVerifier = createCodeVerifier();
86+
this.codeChallenge = createCodeChallenge(this.codeVerifier);
87+
}
88+
89+
@Override
90+
public String getCodeVerifier() {
91+
return codeVerifier;
92+
}
93+
94+
@Override
95+
public String getCodeChallenge() {
96+
return codeChallenge.getCodeChallenge();
97+
}
98+
99+
@Override
100+
public String getCodeChallengeMethod() {
101+
return codeChallenge.getCodeChallengeMethod();
102+
}
103+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*
2+
* Copyright 2023, Google Inc. All rights reserved.
3+
*
4+
* Redistribution and use in source and binary forms, with or without
5+
* modification, are permitted provided that the following conditions are
6+
* met:
7+
*
8+
* * Redistributions of source code must retain the above copyright
9+
* notice, this list of conditions and the following disclaimer.
10+
* * Redistributions in binary form must reproduce the above
11+
* copyright notice, this list of conditions and the following disclaimer
12+
* in the documentation and/or other materials provided with the
13+
* distribution.
14+
*
15+
* * Neither the name of Google Inc. nor the names of its
16+
* contributors may be used to endorse or promote products derived from
17+
* this software without specific prior written permission.
18+
*
19+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
20+
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
21+
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
22+
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
23+
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
24+
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
25+
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
26+
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
27+
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
28+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29+
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30+
*/
31+
32+
package com.google.auth.oauth2;
33+
34+
public interface PKCEProvider {
35+
/**
36+
* Get the code_challenge parameter used in PKCE.
37+
*
38+
* @return The code_challenge String.
39+
*/
40+
String getCodeChallenge();
41+
42+
/**
43+
* Get the code_challenge_method parameter used in PKCE.
44+
*
45+
* <p>Currently possible values are: S256,plain
46+
*
47+
* @return The code_challenge_method String.
48+
*/
49+
String getCodeChallengeMethod();
50+
/**
51+
* Get the code_verifier parameter used in PKCE.
52+
*
53+
* @return The code_verifier String.
54+
*/
55+
String getCodeVerifier();
56+
}

oauth2_http/java/com/google/auth/oauth2/UserAuthorizer.java

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ public class UserAuthorizer {
6767
private final HttpTransportFactory transportFactory;
6868
private final URI tokenServerUri;
6969
private final URI userAuthUri;
70+
private final PKCEProvider pkce;
7071

7172
/**
7273
* Constructor with all parameters.
@@ -79,6 +80,7 @@ public class UserAuthorizer {
7980
* tokens.
8081
* @param tokenServerUri URI of the end point that provides tokens
8182
* @param userAuthUri URI of the Web UI for user consent
83+
* @param pkce PKCE implementation
8284
*/
8385
private UserAuthorizer(
8486
ClientId clientId,
@@ -87,7 +89,8 @@ private UserAuthorizer(
8789
URI callbackUri,
8890
HttpTransportFactory transportFactory,
8991
URI tokenServerUri,
90-
URI userAuthUri) {
92+
URI userAuthUri,
93+
PKCEProvider pkce) {
9194
this.clientId = Preconditions.checkNotNull(clientId);
9295
this.scopes = ImmutableList.copyOf(Preconditions.checkNotNull(scopes));
9396
this.callbackUri = (callbackUri == null) ? DEFAULT_CALLBACK_URI : callbackUri;
@@ -96,6 +99,7 @@ private UserAuthorizer(
9699
this.tokenServerUri = (tokenServerUri == null) ? OAuth2Utils.TOKEN_SERVER_URI : tokenServerUri;
97100
this.userAuthUri = (userAuthUri == null) ? OAuth2Utils.USER_AUTH_URI : userAuthUri;
98101
this.tokenStore = (tokenStore == null) ? new MemoryTokensStorage() : tokenStore;
102+
this.pkce = pkce;
99103
}
100104

101105
/**
@@ -181,6 +185,10 @@ public URL getAuthorizationUrl(String userId, String state, URI baseUri) {
181185
url.put("login_hint", userId);
182186
}
183187
url.put("include_granted_scopes", true);
188+
if (pkce != null) {
189+
url.put("code_challenge", pkce.getCodeChallenge());
190+
url.put("code_challenge_method", pkce.getCodeChallengeMethod());
191+
}
184192
return url.toURL();
185193
}
186194

@@ -248,6 +256,11 @@ public UserCredentials getCredentialsFromCode(String code, URI baseUri) throws I
248256
tokenData.put("client_secret", clientId.getClientSecret());
249257
tokenData.put("redirect_uri", resolvedCallbackUri);
250258
tokenData.put("grant_type", "authorization_code");
259+
260+
if (pkce != null) {
261+
tokenData.put("code_verifier", pkce.getCodeVerifier());
262+
}
263+
251264
UrlEncodedContent tokenContent = new UrlEncodedContent(tokenData);
252265
HttpRequestFactory requestFactory = transportFactory.create().createRequestFactory();
253266
HttpRequest tokenRequest =
@@ -430,6 +443,7 @@ public static class Builder {
430443
private URI userAuthUri;
431444
private Collection<String> scopes;
432445
private HttpTransportFactory transportFactory;
446+
private PKCEProvider pkce;
433447

434448
protected Builder() {}
435449

@@ -441,6 +455,7 @@ protected Builder(UserAuthorizer authorizer) {
441455
this.tokenStore = authorizer.tokenStore;
442456
this.callbackUri = authorizer.callbackUri;
443457
this.userAuthUri = authorizer.userAuthUri;
458+
this.pkce = new DefaultPKCEProvider();
444459
}
445460

446461
public Builder setClientId(ClientId clientId) {
@@ -478,6 +493,20 @@ public Builder setHttpTransportFactory(HttpTransportFactory transportFactory) {
478493
return this;
479494
}
480495

496+
public Builder setPKCEProvider(PKCEProvider pkce) {
497+
if (pkce != null) {
498+
if (pkce.getCodeChallenge() == null
499+
|| pkce.getCodeVerifier() == null
500+
|| pkce.getCodeChallengeMethod() == null) {
501+
502+
throw new IllegalArgumentException(
503+
"PKCE provider contained null implementations. PKCE object must implement all PKCEProvider methods.");
504+
}
505+
}
506+
this.pkce = pkce;
507+
return this;
508+
}
509+
481510
public ClientId getClientId() {
482511
return clientId;
483512
}
@@ -506,9 +535,20 @@ public HttpTransportFactory getHttpTransportFactory() {
506535
return transportFactory;
507536
}
508537

538+
public PKCEProvider getPKCEProvider() {
539+
return pkce;
540+
}
541+
509542
public UserAuthorizer build() {
510543
return new UserAuthorizer(
511-
clientId, scopes, tokenStore, callbackUri, transportFactory, tokenServerUri, userAuthUri);
544+
clientId,
545+
scopes,
546+
tokenStore,
547+
callbackUri,
548+
transportFactory,
549+
tokenServerUri,
550+
userAuthUri,
551+
pkce);
512552
}
513553
}
514554
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
* Copyright 2023, Google Inc. All rights reserved.
3+
*
4+
* Redistribution and use in source and binary forms, with or without
5+
* modification, are permitted provided that the following conditions are
6+
* met:
7+
*
8+
* * Redistributions of source code must retain the above copyright
9+
* notice, this list of conditions and the following disclaimer.
10+
* * Redistributions in binary form must reproduce the above
11+
* copyright notice, this list of conditions and the following disclaimer
12+
* in the documentation and/or other materials provided with the
13+
* distribution.
14+
*
15+
* * Neither the name of Google Inc. nor the names of its
16+
* contributors may be used to endorse or promote products derived from
17+
* this software without specific prior written permission.
18+
*
19+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
20+
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
21+
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
22+
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
23+
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
24+
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
25+
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
26+
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
27+
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
28+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29+
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30+
*/
31+
32+
package com.google.auth.oauth2;
33+
34+
import static org.junit.Assert.assertEquals;
35+
36+
import java.security.MessageDigest;
37+
import java.security.NoSuchAlgorithmException;
38+
import java.util.Base64;
39+
import org.junit.Test;
40+
import org.junit.runner.RunWith;
41+
import org.junit.runners.JUnit4;
42+
43+
@RunWith(JUnit4.class)
44+
public final class DefaultPKCEProviderTest {
45+
@Test
46+
public void testPkceExpected() throws NoSuchAlgorithmException {
47+
PKCEProvider pkce = new DefaultPKCEProvider();
48+
49+
byte[] bytes = pkce.getCodeVerifier().getBytes();
50+
MessageDigest md = MessageDigest.getInstance("SHA-256");
51+
md.update(bytes);
52+
53+
byte[] digest = md.digest();
54+
55+
String expectedCodeChallenge = Base64.getUrlEncoder().encodeToString(digest);
56+
String expectedCodeChallengeMethod = "S256";
57+
58+
assertEquals(pkce.getCodeChallenge(), expectedCodeChallenge);
59+
assertEquals(pkce.getCodeChallengeMethod(), expectedCodeChallengeMethod);
60+
}
61+
}

oauth2_http/javatests/com/google/auth/oauth2/UserAuthorizerTest.java

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ public class UserAuthorizerTest {
7272
private static final URI CALLBACK_URI = URI.create("/testcallback");
7373
private static final String CODE = "thisistheend";
7474
private static final URI BASE_URI = URI.create("http://example.com/foo");
75+
private static final PKCEProvider pkce = new DefaultPKCEProvider();
7576

7677
@Test
7778
public void constructorMinimum() {
@@ -148,6 +149,7 @@ public void getAuthorizationUrl() throws IOException {
148149
.setScopes(DUMMY_SCOPES)
149150
.setCallbackUri(CALLBACK_URI)
150151
.setUserAuthUri(AUTH_URI)
152+
.setPKCEProvider(pkce)
151153
.build();
152154

153155
URL authorizationUrl = authorizer.getAuthorizationUrl(USER_ID, CUSTOM_STATE, BASE_URI);
@@ -164,6 +166,8 @@ public void getAuthorizationUrl() throws IOException {
164166
assertEquals(CLIENT_ID_VALUE, parameters.get("client_id"));
165167
assertEquals(DUMMY_SCOPE, parameters.get("scope"));
166168
assertEquals("code", parameters.get("response_type"));
169+
assertEquals(pkce.getCodeChallenge(), parameters.get("code_challenge"));
170+
assertEquals(pkce.getCodeChallengeMethod(), parameters.get("code_challenge_method"));
167171
}
168172

169173
@Test
@@ -471,4 +475,33 @@ public void revokeAuthorization_revokesAndClears() throws IOException {
471475
UserCredentials credentials2 = authorizer.getCredentials(USER_ID);
472476
assertNull(credentials2);
473477
}
478+
479+
@Test(expected = IllegalArgumentException.class)
480+
public void illegalPKCEProvider() {
481+
PKCEProvider pkce =
482+
new PKCEProvider() {
483+
@Override
484+
public String getCodeVerifier() {
485+
return null;
486+
}
487+
488+
@Override
489+
public String getCodeChallengeMethod() {
490+
return null;
491+
}
492+
493+
@Override
494+
public String getCodeChallenge() {
495+
return null;
496+
}
497+
};
498+
499+
UserAuthorizer authorizer =
500+
UserAuthorizer.newBuilder()
501+
.setClientId(CLIENT_ID)
502+
.setScopes(DUMMY_SCOPES)
503+
.setTokenStore(new MemoryTokensStorage())
504+
.setPKCEProvider(pkce)
505+
.build();
506+
}
474507
}

0 commit comments

Comments
 (0)