Skip to content

Commit 13433cd

Browse files
authored
feat: add PKCE support to AuthorizationCodeFlow (#470)
* Initial test code for a PKCE enabled Authorization Code Flow * WIP: work on README.md * Script to initialize keycloak by adding client via REST API. * Improve keycloak init script and some code cleanup. Still WIP. * WIP: work on README.md * Working PKCE AuthorizationCodeFlow. Some cleanup of test classes. * Add scopes back to the AuthorizationCodeRequestUrl creation. * Simplify code by moving PKCE entirely into the AuthorizationCodeFlow class. Add documentation. * Remove wildcard imports as that seems to be the way to do things here. * Add @SInCE annotation in JavaDoc to the PKCE parameters of the autorization url class. * Add PKCE unit test, documentation and minor cleanup of dependencies for code sample. * Add PKCE unit test, documentation and minor cleanup of dependencies for code sample. * Annotate PKCE with Beta annotation. * Responding to code review comments * Responding to more PR comments * Improve Keycloak PKCE sample documentation * Add license header with copyright to new files. Improve documentation.
1 parent 6447917 commit 13433cd

File tree

9 files changed

+466
-4
lines changed

9 files changed

+466
-4
lines changed

google-oauth-client/src/main/java/com/google/api/client/auth/oauth2/AuthorizationCodeFlow.java

Lines changed: 98 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,14 @@
1717
import com.google.api.client.auth.oauth2.Credential.AccessMethod;
1818
import com.google.api.client.http.GenericUrl;
1919
import com.google.api.client.http.HttpExecuteInterceptor;
20+
import com.google.api.client.http.HttpRequest;
2021
import com.google.api.client.http.HttpRequestInitializer;
2122
import com.google.api.client.http.HttpTransport;
23+
import com.google.api.client.http.UrlEncodedContent;
2224
import com.google.api.client.json.JsonFactory;
25+
import com.google.api.client.util.Base64;
2326
import com.google.api.client.util.Beta;
27+
import com.google.api.client.util.Data;
2428
import com.google.api.client.util.Clock;
2529
import com.google.api.client.util.Joiner;
2630
import com.google.api.client.util.Lists;
@@ -29,8 +33,12 @@
2933
import com.google.api.client.util.store.DataStoreFactory;
3034

3135
import java.io.IOException;
36+
import java.security.MessageDigest;
37+
import java.security.NoSuchAlgorithmException;
38+
import java.security.SecureRandom;
3239
import java.util.Collection;
3340
import java.util.Collections;
41+
import java.util.Map;
3442

3543
import static com.google.api.client.util.Strings.isNullOrEmpty;
3644

@@ -85,6 +93,9 @@ public class AuthorizationCodeFlow {
8593
/** Authorization server encoded URL. */
8694
private final String authorizationServerEncodedUrl;
8795

96+
/** The Proof Key for Code Exchange (PKCE) or {@code null} if this flow should not use PKCE. */
97+
private final PKCE pkce;
98+
8899
/** Credential persistence store or {@code null} for none. */
89100
@Beta
90101
@Deprecated
@@ -159,6 +170,7 @@ protected AuthorizationCodeFlow(Builder builder) {
159170
clock = Preconditions.checkNotNull(builder.clock);
160171
credentialCreatedListener = builder.credentialCreatedListener;
161172
refreshListeners = Collections.unmodifiableCollection(builder.refreshListeners);
173+
pkce = builder.pkce;
162174
}
163175

164176
/**
@@ -182,8 +194,13 @@ public void doGet(HttpServletRequest request, HttpServletResponse response) thro
182194
* </pre>
183195
*/
184196
public AuthorizationCodeRequestUrl newAuthorizationUrl() {
185-
return new AuthorizationCodeRequestUrl(authorizationServerEncodedUrl, clientId).setScopes(
186-
scopes);
197+
AuthorizationCodeRequestUrl url = new AuthorizationCodeRequestUrl(authorizationServerEncodedUrl, clientId);
198+
url.setScopes(scopes);
199+
if (pkce != null) {
200+
url.setCodeChallenge(pkce.getChallenge());
201+
url.setCodeChallengeMethod(pkce.getChallengeMethod());
202+
}
203+
return url;
187204
}
188205

189206
/**
@@ -206,9 +223,20 @@ static TokenResponse requestAccessToken(AuthorizationCodeFlow flow, String code)
206223
* @param authorizationCode authorization code.
207224
*/
208225
public AuthorizationCodeTokenRequest newTokenRequest(String authorizationCode) {
226+
HttpExecuteInterceptor pkceClientAuthenticationWrapper = new HttpExecuteInterceptor() {
227+
@Override
228+
public void intercept(HttpRequest request) throws IOException {
229+
clientAuthentication.intercept(request);
230+
if (pkce != null) {
231+
Map<String, Object> data = Data.mapOf(UrlEncodedContent.getContent(request).getData());
232+
data.put("code_verifier", pkce.getVerifier());
233+
}
234+
}
235+
};
236+
209237
return new AuthorizationCodeTokenRequest(transport, jsonFactory,
210238
new GenericUrl(tokenServerEncodedUrl), authorizationCode).setClientAuthentication(
211-
clientAuthentication).setRequestInitializer(requestInitializer).setScopes(scopes);
239+
pkceClientAuthenticationWrapper).setRequestInitializer(requestInitializer).setScopes(scopes);
212240
}
213241

214242
/**
@@ -412,6 +440,61 @@ public interface CredentialCreatedListener {
412440
void onCredentialCreated(Credential credential, TokenResponse tokenResponse) throws IOException;
413441
}
414442

443+
/**
444+
* An implementation of <a href="https://tools.ietf.org/html/rfc7636">Proof Key for Code Exchange</a>
445+
* which, according to the <a href="https://tools.ietf.org/html/rfc8252#section-6">OAuth 2.0 for Native Apps RFC</a>,
446+
* is mandatory for public native apps.
447+
*/
448+
private static class PKCE {
449+
private final String verifier;
450+
private String challenge;
451+
private String challengeMethod;
452+
453+
public PKCE() {
454+
verifier = generateVerifier();
455+
generateChallenge(verifier);
456+
}
457+
458+
private static String generateVerifier() {
459+
SecureRandom sr = new SecureRandom();
460+
byte[] code = new byte[32];
461+
sr.nextBytes(code);
462+
return Base64.encodeBase64URLSafeString(code);
463+
}
464+
465+
/**
466+
* Create the PKCE code verifier. It uses the S256 method but
467+
* falls back to using the 'plain' method in the unlikely case
468+
* that the SHA-256 MessageDigest algorithm implementation can't be
469+
* loaded.
470+
*/
471+
private void generateChallenge(String verifier) {
472+
try {
473+
byte[] bytes = verifier.getBytes();
474+
MessageDigest md = MessageDigest.getInstance("SHA-256");
475+
md.update(bytes, 0, bytes.length);
476+
byte[] digest = md.digest();
477+
challenge = Base64.encodeBase64URLSafeString(digest);
478+
challengeMethod = "S256";
479+
} catch (NoSuchAlgorithmException e) {
480+
challenge = verifier;
481+
challengeMethod = "plain";
482+
}
483+
}
484+
485+
public String getVerifier() {
486+
return verifier;
487+
}
488+
489+
public String getChallenge() {
490+
return challenge;
491+
}
492+
493+
public String getChallengeMethod() {
494+
return challengeMethod;
495+
}
496+
}
497+
415498
/**
416499
* Authorization code flow builder.
417500
*
@@ -448,6 +531,8 @@ public static class Builder {
448531
/** Authorization server encoded URL. */
449532
String authorizationServerEncodedUrl;
450533

534+
PKCE pkce;
535+
451536
/** Credential persistence store or {@code null} for none. */
452537
@Deprecated
453538
@Beta
@@ -784,6 +869,16 @@ public Builder setRequestInitializer(HttpRequestInitializer requestInitializer)
784869
return this;
785870
}
786871

872+
/**
873+
* Enables Proof Key for Code Exchange (PKCE) for this Athorization Code Flow.
874+
* @since 1.31
875+
*/
876+
@Beta
877+
public Builder enablePKCE() {
878+
this.pkce = new PKCE();
879+
return this;
880+
}
881+
787882
/**
788883
* Sets the collection of scopes.
789884
*

google-oauth-client/src/main/java/com/google/api/client/auth/oauth2/AuthorizationCodeRequestUrl.java

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
package com.google.api.client.auth.oauth2;
1616

17+
import com.google.api.client.util.Key;
18+
1719
import java.util.Collection;
1820
import java.util.Collections;
1921

@@ -52,6 +54,20 @@ public void doGet(HttpServletRequest request, HttpServletResponse response) thro
5254
*/
5355
public class AuthorizationCodeRequestUrl extends AuthorizationRequestUrl {
5456

57+
/**
58+
* The PKCE <a href="https://tools.ietf.org/html/rfc7636#section-4.3">Code Challenge</a>.
59+
* @since 1.31
60+
*/
61+
@Key("code_challenge")
62+
String codeChallenge;
63+
64+
/**
65+
* The PKCE <a href="https://tools.ietf.org/html/rfc7636#section-4.3">Code Challenge Method</a>.
66+
* @since 1.31
67+
*/
68+
@Key("code_challenge_method")
69+
String codeChallengeMethod;
70+
5571
/**
5672
* @param authorizationServerEncodedUrl authorization server encoded URL
5773
* @param clientId client identifier
@@ -60,6 +76,44 @@ public AuthorizationCodeRequestUrl(String authorizationServerEncodedUrl, String
6076
super(authorizationServerEncodedUrl, clientId, Collections.singleton("code"));
6177
}
6278

79+
/**
80+
* Get the code challenge (<a href="https://tools.ietf.org/html/rfc7636#section-4.3">details</a>).
81+
*
82+
* @since 1.31
83+
*/
84+
public String getCodeChallenge() {
85+
return codeChallenge;
86+
}
87+
88+
/**
89+
* Get the code challenge method (<a href="https://tools.ietf.org/html/rfc7636#section-4.3">details</a>).
90+
*
91+
* @since 1.31
92+
*/
93+
public String getCodeChallengeMethod() {
94+
return codeChallengeMethod;
95+
}
96+
97+
/**
98+
* Set the code challenge (<a href="https://tools.ietf.org/html/rfc7636#section-4.3">details</a>).
99+
* @param codeChallenge the code challenge.
100+
*
101+
* @since 1.31
102+
*/
103+
public void setCodeChallenge(String codeChallenge) {
104+
this.codeChallenge = codeChallenge;
105+
}
106+
107+
/**
108+
* Set the code challenge method (<a href="https://tools.ietf.org/html/rfc7636#section-4.3">details</a>).
109+
* @param codeChallengeMethod the code challenge method.
110+
*
111+
* @since 1.31
112+
*/
113+
public void setCodeChallengeMethod(String codeChallengeMethod) {
114+
this.codeChallengeMethod = codeChallengeMethod;
115+
}
116+
63117
@Override
64118
public AuthorizationCodeRequestUrl setResponseTypes(Collection<String> responseTypes) {
65119
return (AuthorizationCodeRequestUrl) super.setResponseTypes(responseTypes);

google-oauth-client/src/test/java/com/google/api/client/auth/oauth2/AuthorizationCodeFlowTest.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
import java.util.Arrays;
2424
import java.util.Collection;
2525
import java.util.Collections;
26+
import java.util.HashSet;
27+
import java.util.Set;
2628

2729
/**
2830
* Tests {@link AuthorizationCodeFlow}.
@@ -123,4 +125,24 @@ public void subsetTestNewAuthorizationUrl(Collection<String> scopes) {
123125
assertEquals(Joiner.on(' ').join(scopes), url.getScopes());
124126
}
125127
}
128+
129+
public void testPKCE() {
130+
AuthorizationCodeFlow flow =
131+
new AuthorizationCodeFlow.Builder(BearerToken.queryParameterAccessMethod(),
132+
new AccessTokenTransport(),
133+
new JacksonFactory(),
134+
TOKEN_SERVER_URL,
135+
new BasicAuthentication(CLIENT_ID, CLIENT_SECRET),
136+
CLIENT_ID,
137+
"https://example.com")
138+
.enablePKCE()
139+
.build();
140+
141+
AuthorizationCodeRequestUrl url = flow.newAuthorizationUrl();
142+
assertNotNull(url.getCodeChallenge());
143+
assertNotNull(url.getCodeChallengeMethod());
144+
Set<String> methods = new HashSet<>(Arrays.asList("plain", "s256"));
145+
assertTrue(methods.contains(url.getCodeChallengeMethod().toLowerCase()));
146+
assertTrue(url.getCodeChallenge().length() > 0);
147+
}
126148
}

pom.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
<module>google-oauth-client-java6</module>
6666
<module>google-oauth-client-jetty</module>
6767
<module>samples/dailymotion-cmdline-sample</module>
68+
<module>samples/keycloak-pkce-cmdline-sample</module>
6869

6970
<!-- For deployment reasons, a deployable artifact must be the last one. -->
7071
<module>google-oauth-client-assembly</module>

samples/dailymotion-cmdline-sample/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
## Command-Line Instructions
88

9-
**Prerequisites:** install [Java 6 or higher][install-java], [git][install-git], and
9+
**Prerequisites:** install [Java 7 or higher][install-java], [git][install-git], and
1010
[Maven][install-maven]. You may need to set your `JAVA_HOME`.
1111

1212
1. Check out the sample code:
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Instructions for the Keycloak OAuth2 with PKCE Command-Line Sample
2+
3+
## Browse Online
4+
5+
[Browse Source][browse-source], or main file [PKCESample.java][main-source].
6+
7+
## Command-Line Instructions
8+
9+
**Prerequisites:** install [Java 7 or higher][install-java], [git][install-git], and
10+
[Maven][install-maven]. You may need to set your `JAVA_HOME`.
11+
You'll also need [Docker][install-docker].
12+
13+
1. Check out the sample code:
14+
15+
```bash
16+
git clone https://github.com/google/google-oauth-java-client.git
17+
cd google-oauth-java-client
18+
```
19+
20+
2. Run keycloak in a docker container:
21+
22+
```
23+
docker run -p 8080:8080 -e KEYCLOAK_USER=admin -e KEYCLOAK_PASSWORD=admin quay.io/keycloak/keycloak:10.0.1
24+
```
25+
26+
3. Run the sample:
27+
28+
```bash
29+
mvn install
30+
mvn exec:java -pl samples/keycloak-pkce-cmdline-sample
31+
```
32+
33+
This will open up the Keycloak login page where you can log in with the username/password specified
34+
when running the Keycloak docker container above (`admin / admin`). Once you log in, the application
35+
will print out a message that it successfully obtained an access token.
36+
37+
[browse-source]: https://github.com/google/google-oauth-java-client/tree/dev/samples/keycloak-pkce-cmdline-sample
38+
[main-source]: https://github.com/google/google-oauth-java-client/blob/dev/samples/keycloak-pkce-cmdline-sample/src/main/java/com/google/api/services/samples/keycloak/cmdline/PKCESample.java
39+
[install-java]: https://java.com/
40+
[install-git]: https://git-scm.com
41+
[install-maven]: https://maven.apache.org
42+
[install-docker]: https://docs.docker.com/get-docker/

0 commit comments

Comments
 (0)