Skip to content

Commit 69dc5fb

Browse files
committed
added pkce authorization to the flow
1 parent 2c3fbbb commit 69dc5fb

File tree

8 files changed

+272
-106
lines changed

8 files changed

+272
-106
lines changed

src/main/java/com/docusign/DSConfiguration.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,15 @@ public class DSConfiguration {
7070
@Value("${spring.security.oauth2.client.registration.jwt.client-id}")
7171
private String userId;
7272

73+
@Value("${spring.security.oauth2.client.registration.acg.client-secret}")
74+
private String secretUserId;
75+
76+
@Value("${spring.security.oauth2.client.provider.acg.token-uri}")
77+
private String tokenEndpoint;
78+
79+
@Value("${spring.security.oauth2.client.provider.acg.authorization-uri}")
80+
private String authorizationEndpoint;
81+
7382
@Value("${jwt.grant.sso.redirect-url}")
7483
private String jwtRedirectURL;
7584

@@ -158,7 +167,8 @@ public ManifestStructure getCodeExamplesText() {
158167
}
159168

160169
try {
161-
codeExamplesText = new ObjectMapper().readValue(loadFileData(codeExamplesManifest), ManifestStructure.class);
170+
codeExamplesText = new ObjectMapper().readValue(loadFileData(codeExamplesManifest),
171+
ManifestStructure.class);
162172
} catch (Exception e) {
163173
e.printStackTrace();
164174
}

src/main/java/com/docusign/WebSecurityConfig.java

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,13 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
2828
try {
2929
authorize
3030
.antMatchers("/", "/error**", "/assets/**", "/ds/mustAuthenticate**",
31-
"/ds/authenticate**", "/ds/selectApi**", "/con001")
31+
"/ds/authenticate**", "/ds/selectApi**", "/con001", "/pkce")
3232
.permitAll()
3333
.anyRequest().authenticated()
3434
.and()
3535
.exceptionHandling()
3636
.authenticationEntryPoint(
37-
new LoginUrlAuthenticationEntryPoint("/ds/mustAuthenticate")
38-
);
37+
new LoginUrlAuthenticationEntryPoint("/ds/mustAuthenticate"));
3938
} catch (Exception e) {
4039
throw new RuntimeException(e);
4140
}
@@ -44,8 +43,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
4443
.oauth2Login(Customizer.withDefaults())
4544
.oauth2Client(Customizer.withDefaults())
4645
.logout(logout -> logout
47-
.logoutSuccessUrl("/")
48-
)
46+
.logoutSuccessUrl("/"))
4947
.csrf().disable();
5048

5149
return http.build();

src/main/java/com/docusign/core/controller/IndexController.java

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import com.docusign.core.model.AuthType;
88
import com.docusign.core.model.Session;
99
import com.docusign.core.model.User;
10+
import com.docusign.core.security.acg.ACGAuthenticationMethod;
1011
import com.docusign.core.security.jwt.JWTAuthenticationMethod;
1112
import org.apache.commons.lang3.StringUtils;
1213
import org.springframework.beans.factory.annotation.Autowired;
@@ -112,7 +113,8 @@ public String index(ModelMap model, HttpServletResponse response) throws Excepti
112113
}
113114

114115
@GetMapping(path = "/ds/mustAuthenticate")
115-
public ModelAndView mustAuthenticateController(ModelMap model, HttpServletRequest req, HttpServletResponse resp) throws IOException {
116+
public ModelAndView mustAuthenticateController(ModelMap model, HttpServletRequest req, HttpServletResponse resp)
117+
throws IOException {
116118
model.addAttribute(LAUNCHER_TEXTS, config.getCodeExamplesText().SupportingTexts);
117119
model.addAttribute(ATTR_TITLE, config.getCodeExamplesText().SupportingTexts.LoginPage.LoginButton);
118120

@@ -125,7 +127,8 @@ public ModelAndView mustAuthenticateController(ModelMap model, HttpServletReques
125127
return new ModelAndView(new JWTAuthenticationMethod().loginUsingJWT(config, session, redirectURL));
126128
}
127129

128-
boolean isRedirectToMonitor = redirectURL.toLowerCase().contains("/m") && !redirectURL.toLowerCase().contains("/mae");
130+
boolean isRedirectToMonitor = redirectURL.toLowerCase().contains("/m")
131+
&& !redirectURL.toLowerCase().contains("/mae");
129132
if (session.isRefreshToken() || config.getQuickstart().equals("true")) {
130133
config.setQuickstart("false");
131134

@@ -148,8 +151,24 @@ private ModelAndView checkForMonitorRedirects(String redirectURL) {
148151
return new ModelAndView(new JWTAuthenticationMethod().loginUsingJWT(config, session, redirectURL));
149152
}
150153

154+
@GetMapping("/pkce")
155+
public RedirectView pkce(String code, String state, HttpServletRequest req, HttpServletResponse resp)
156+
throws Exception {
157+
String redirectURL = getRedirectURLForJWTAuthentication(req, resp);
158+
RedirectView redirect;
159+
try {
160+
redirect = new ACGAuthenticationMethod().exchangeCodeForToken(code, config, session, redirectURL);
161+
} catch (Exception e) {
162+
redirect = getRedirectView(AuthType.AGC);
163+
this.session.setIsPKCEWorking(false);
164+
}
165+
166+
return redirect;
167+
}
168+
151169
@PostMapping("/ds/authenticate")
152-
public RedirectView authenticate(ModelMap model, @RequestBody MultiValueMap<String, String> formParams, HttpServletRequest req, HttpServletResponse resp) throws IOException {
170+
public RedirectView authenticate(ModelMap model, @RequestBody MultiValueMap<String, String> formParams,
171+
HttpServletRequest req, HttpServletResponse resp) throws Exception {
153172
if (!formParams.containsKey("selectAuthType")) {
154173
model.addAttribute("message", "Select option with selectAuthType name must be provided.");
155174
return new RedirectView("pages/error");
@@ -165,14 +184,18 @@ public RedirectView authenticate(ModelMap model, @RequestBody MultiValueMap<Stri
165184
return new JWTAuthenticationMethod().loginUsingJWT(config, session, redirectURL);
166185
} else {
167186
this.session.setAuthTypeSelected(AuthType.AGC);
168-
return getRedirectView(authTypeSelected);
187+
if (this.session.getIsPKCEWorking()) {
188+
return new ACGAuthenticationMethod().initiateAuthorization(config);
189+
} else {
190+
return getRedirectView(authTypeSelected);
191+
}
169192
}
170193
}
171194

172195
private String getRedirectURLForJWTAuthentication(HttpServletRequest req, HttpServletResponse resp) {
173196
SavedRequest savedRequest = requestCache.getRequest(req, resp);
174197

175-
String[] examplesCodes = new String[]{
198+
String[] examplesCodes = new String[] {
176199
ApiIndex.CLICK.getExamplesPathCode(),
177200
ApiIndex.ESIGNATURE.getExamplesPathCode(),
178201
ApiIndex.MONITOR.getExamplesPathCode(),
@@ -185,10 +208,10 @@ private String getRedirectURLForJWTAuthentication(HttpServletRequest req, HttpSe
185208
Integer indexOfExampleCodeInRedirect = StringUtils.indexOfAny(savedRequest.getRedirectUrl(), examplesCodes);
186209

187210
if (indexOfExampleCodeInRedirect != -1) {
188-
Boolean hasNumbers = savedRequest.getRedirectUrl().substring(indexOfExampleCodeInRedirect).matches(".*\\d.*");
211+
Boolean hasNumbers = savedRequest.getRedirectUrl().substring(indexOfExampleCodeInRedirect)
212+
.matches(".*\\d.*");
189213

190-
return "GET".equals(savedRequest.getMethod()) && hasNumbers ?
191-
savedRequest.getRedirectUrl() : "/";
214+
return "GET".equals(savedRequest.getMethod()) && hasNumbers ? savedRequest.getRedirectUrl() : "/";
192215
}
193216
}
194217

@@ -197,8 +220,8 @@ private String getRedirectURLForJWTAuthentication(HttpServletRequest req, HttpSe
197220

198221
@GetMapping(path = "/ds-return")
199222
public String returnController(@RequestParam(value = ATTR_STATE, required = false) String state,
200-
@RequestParam(value = ATTR_EVENT, required = false) String event,
201-
@RequestParam(required = false) String envelopeId, ModelMap model) {
223+
@RequestParam(value = ATTR_EVENT, required = false) String event,
224+
@RequestParam(required = false) String envelopeId, ModelMap model) {
202225
model.addAttribute(LAUNCHER_TEXTS, config.getCodeExamplesText().SupportingTexts);
203226
model.addAttribute(ATTR_TITLE, "Return from DocuSign");
204227
model.addAttribute(ATTR_EVENT, event);

src/main/java/com/docusign/core/model/Session.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,7 @@
1212
import java.util.UUID;
1313

1414
@Component
15-
@Scope(value = WebApplicationContext.SCOPE_SESSION,
16-
proxyMode = ScopedProxyMode.TARGET_CLASS)
15+
@Scope(value = WebApplicationContext.SCOPE_SESSION, proxyMode = ScopedProxyMode.TARGET_CLASS)
1716
@Data
1817
public class Session implements Serializable {
1918
private static final long serialVersionUID = 2695379118371574037L;
@@ -75,4 +74,6 @@ public class Session implements Serializable {
7574
private String instanceId;
7675

7776
private Boolean isWorkflowPublished = false;
77+
78+
private Boolean isPKCEWorking = true;
7879
}

src/main/java/com/docusign/core/security/jwt/JWTOAuth2User.java renamed to src/main/java/com/docusign/core/security/JWTOAuth2User.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package com.docusign.core.security.jwt;
1+
package com.docusign.core.security;
22

33
import com.docusign.esign.client.auth.OAuth;
44
import com.fasterxml.jackson.databind.ObjectMapper;
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package com.docusign.core.security;
2+
3+
import java.security.MessageDigest;
4+
import java.security.NoSuchAlgorithmException;
5+
import java.io.IOException;
6+
import java.nio.charset.StandardCharsets;
7+
import java.util.ArrayList;
8+
import java.util.Arrays;
9+
import java.util.Base64;
10+
import java.util.List;
11+
import java.util.Random;
12+
13+
import org.springframework.security.core.context.SecurityContextHolder;
14+
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
15+
import com.docusign.core.model.ApiType;
16+
import com.docusign.core.model.Session;
17+
import com.docusign.esign.client.auth.OAuth;
18+
import com.fasterxml.jackson.databind.JsonNode;
19+
import com.fasterxml.jackson.databind.ObjectMapper;
20+
21+
public class SecurityHelpers {
22+
public static List<String> getScopeList() {
23+
List<String> scopes = new ArrayList<>();
24+
for (ApiType scope : ApiType.values()) {
25+
scopes.addAll(Arrays.asList(scope.getScopes()));
26+
}
27+
return scopes;
28+
}
29+
30+
public static String generateCodeVerifier() {
31+
byte[] randomBytes = new byte[32];
32+
new Random().nextBytes(randomBytes);
33+
return Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes);
34+
}
35+
36+
public static String generateCodeChallenge(String codeVerifier) throws NoSuchAlgorithmException {
37+
MessageDigest digest = MessageDigest.getInstance("SHA-256");
38+
byte[] hash = digest.digest(codeVerifier.getBytes(StandardCharsets.UTF_8));
39+
return Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
40+
}
41+
42+
public static String parseJsonField(String jsonResponse, String field) throws IOException {
43+
ObjectMapper mapper = new ObjectMapper();
44+
JsonNode jsonNode = mapper.readTree(jsonResponse);
45+
return jsonNode.get(field).asText();
46+
}
47+
48+
public static void setSpringSecurityAuthentication(List<String> scopes, String oAuthToken, OAuth.UserInfo userInfo,
49+
String accountId, Session session, String expiresIn) {
50+
JWTOAuth2User principal = new JWTOAuth2User();
51+
principal.setAuthorities(scopes);
52+
principal.setCreated(userInfo.getCreated());
53+
principal.setName(userInfo.getName());
54+
principal.setGivenName(userInfo.getGivenName());
55+
principal.setFamilyName(userInfo.getFamilyName());
56+
principal.setSub(userInfo.getSub());
57+
principal.setEmail(userInfo.getEmail());
58+
principal.setAccounts(userInfo.getAccounts());
59+
principal.setAccessToken(new OAuth.OAuthToken().accessToken(oAuthToken));
60+
61+
session.setTokenExpirationTime(System.currentTimeMillis() + Integer.parseInt(expiresIn) * 1000L);
62+
63+
OAuth2AuthenticationToken token = new OAuth2AuthenticationToken(principal, principal.getAuthorities(),
64+
accountId);
65+
SecurityContextHolder.getContext().setAuthentication(token);
66+
}
67+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package com.docusign.core.security.acg;
2+
3+
import java.io.IOException;
4+
import java.nio.charset.StandardCharsets;
5+
import java.net.URI;
6+
import java.net.URLEncoder;
7+
import java.net.http.HttpClient;
8+
import java.net.http.HttpRequest;
9+
import java.net.http.HttpResponse;
10+
import java.util.Base64;
11+
import java.util.List;
12+
import org.springframework.web.servlet.view.RedirectView;
13+
14+
import com.docusign.DSConfiguration;
15+
import com.docusign.core.model.Session;
16+
import com.docusign.core.security.SecurityHelpers;
17+
import com.docusign.esign.client.ApiClient;
18+
import com.docusign.esign.client.auth.OAuth;
19+
20+
public class ACGAuthenticationMethod {
21+
private static final String REDIRECT_URI = "/pkce";
22+
private static final String STATE = "random_state_string";
23+
private static String codeVerifier;
24+
private static String codeChallenge;
25+
26+
public RedirectView initiateAuthorization(DSConfiguration configuration) throws Exception {
27+
List<String> scopes = SecurityHelpers.getScopeList();
28+
29+
codeVerifier = SecurityHelpers.generateCodeVerifier();
30+
codeChallenge = SecurityHelpers.generateCodeChallenge(codeVerifier);
31+
32+
String authorizationURL = String.format(
33+
"%s&redirect_uri=%s&scope=%s&client_id=%s&state=%s&response_type=code&code_challenge=%s&code_challenge_method=S256",
34+
configuration.getAuthorizationEndpoint(),
35+
URLEncoder.encode(configuration.getAppUrl() + REDIRECT_URI, StandardCharsets.UTF_8),
36+
URLEncoder.encode(String.join(" ", scopes), StandardCharsets.UTF_8), configuration.getUserId(), STATE,
37+
codeChallenge);
38+
39+
return new RedirectView(authorizationURL);
40+
}
41+
42+
public RedirectView exchangeCodeForToken(String oAuthToken, DSConfiguration configuration, Session session,
43+
String redirect)
44+
throws Exception {
45+
String requestBody = buildRequestBody(oAuthToken);
46+
String authHeader = generateAuthHeader(configuration);
47+
48+
HttpClient client = HttpClient.newHttpClient();
49+
HttpRequest request = HttpRequest.newBuilder()
50+
.uri(URI.create(configuration.getTokenEndpoint()))
51+
.header("Authorization", "Basic " + authHeader)
52+
.header("Content-Type", "application/x-www-form-urlencoded")
53+
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
54+
.build();
55+
56+
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
57+
58+
if (response.statusCode() == 200) {
59+
processTokenResponse(response.body(), configuration, session);
60+
} else {
61+
throw new IOException("Failed to exchange code for token. Status code: " + response.statusCode());
62+
}
63+
64+
return new RedirectView(redirect);
65+
}
66+
67+
private String buildRequestBody(String oAuthToken) throws IOException {
68+
return "grant_type=authorization_code" +
69+
"&code=" + URLEncoder.encode(oAuthToken, StandardCharsets.UTF_8) +
70+
"&redirect_uri=" + URLEncoder.encode(REDIRECT_URI, StandardCharsets.UTF_8) +
71+
"&code_verifier=" + URLEncoder.encode(codeVerifier, StandardCharsets.UTF_8);
72+
}
73+
74+
private String generateAuthHeader(DSConfiguration configuration) {
75+
return Base64.getEncoder().encodeToString(
76+
(configuration.getUserId() + ":" + configuration.getSecretUserId()).getBytes(StandardCharsets.UTF_8));
77+
}
78+
79+
private void processTokenResponse(String responseBody, DSConfiguration configuration, Session session)
80+
throws Exception {
81+
ApiClient apiClient = new ApiClient(configuration.getBasePath());
82+
String accessToken = SecurityHelpers.parseJsonField(responseBody, "access_token");
83+
String expiresIn = SecurityHelpers.parseJsonField(responseBody, "expires_in");
84+
85+
OAuth.UserInfo userInfo = apiClient.getUserInfo(accessToken);
86+
String accountId = userInfo.getAccounts().size() > 0 ? userInfo.getAccounts().get(0).getAccountId() : "";
87+
88+
SecurityHelpers.setSpringSecurityAuthentication(SecurityHelpers.getScopeList(), accessToken, userInfo,
89+
accountId, session,
90+
expiresIn);
91+
}
92+
}

0 commit comments

Comments
 (0)