001package ca.cdr.test.app.clients; 002/*- 003 * #%L 004 * Smile CDR - CDR 005 * %% 006 * Copyright (C) 2016 - 2026 Smile CDR, Inc. 007 * %% 008 * All rights reserved. 009 * #L% 010 */ 011 012import com.google.gson.Gson; 013import com.google.gson.JsonElement; 014import com.google.gson.JsonObject; 015import jakarta.annotation.Nonnull; 016import jakarta.annotation.Nullable; 017import org.apache.commons.codec.binary.Base64; 018import org.apache.http.client.utils.URLEncodedUtils; 019import org.slf4j.Logger; 020import org.slf4j.LoggerFactory; 021import org.springframework.http.HttpHeaders; 022import org.springframework.http.MediaType; 023import org.springframework.http.ResponseEntity; 024import org.springframework.web.client.RestClient; 025import org.springframework.web.client.RestClientException; 026import org.springframework.util.LinkedMultiValueMap; 027import org.springframework.util.MultiValueMap; 028import org.springframework.web.util.UriComponentsBuilder; 029import tools.jackson.databind.JsonNode; 030 031import java.io.IOException; 032import java.net.URI; 033import java.net.URLEncoder; 034import java.nio.charset.StandardCharsets; 035import java.util.Arrays; 036import java.util.List; 037import java.util.Objects; 038 039import static org.apache.commons.lang3.StringUtils.isNotBlank; 040 041// Created by Claude Sonnet 4 042 043/** 044 * A client for performing SMART on FHIR OAuth 2.0 authorization flows. 045 * 046 * <p>This client provides methods to interact with SMART on FHIR OAuth 2.0 endpoints 047 * and supports the following OAuth 2.0 flows:</p> 048 * <ul> 049 * <li><strong>Authorization Code Flow</strong> - For web applications requiring user consent</li> 050 * <li><strong>Client Credentials Flow</strong> - For backend services, authenticating with a 051 * client secret or a signed JWT assertion</li> 052 * <li><strong>Refresh Token Flow</strong></li> 053 * <li><strong>Token Introspection</strong> and <strong>OpenID Connect discovery</strong></li> 054 * </ul> 055 * 056 * <p>The client handles HTTP communication with the SMART authorization server, 057 * form-based authentication, CSRF token management, and token exchange operations.</p> 058 * 059 * <h3>Thread Safety:</h3> 060 * <p>This client is not thread-safe. Each thread should use its own instance 061 * or external synchronization should be applied.</p> 062 * 063 * @author Claude Sonnet 4 064 * @see <a href="http://hl7.org/fhir/smart-app-launch/">SMART App Launch Framework</a> 065 * @see <a href="https://tools.ietf.org/html/rfc6749">OAuth 2.0 Authorization Framework</a> 066 */ 067public class OutboundSmartClient { 068 private static final Logger ourLog = LoggerFactory.getLogger(OutboundSmartClient.class); 069 070 private static final String CLIENT_ASSERTION_TYPE_JWT_BEARER = 071 "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; 072 private static final String GRANT_TYPE_JWT_BEARER = "urn:ietf:params:oauth:grant-type:jwt-bearer"; 073 074 private final RestClient myRestClient; 075 private final String mySmartRootUrl; 076 077 /** 078 * Creates a new OutboundSmartClient instance. 079 * 080 * @param theRestClient the REST client to use for HTTP communication with the SMART server. 081 * This client should be configured with appropriate timeouts, SSL settings, 082 * and cookie management as needed for the target SMART server. 083 * @param theSmartRootUrl the root URL of the SMART authorization server (e.g., "https://auth.example.com"), 084 * including any context path. This should not include trailing slashes or 085 * specific endpoints. When it has a context path, {@code theRestClient}'s base 086 * URL must end in a slash so that request paths resolve beneath it. 087 * Its message converters are replaced by ones that read JSON with Jackson 3. 088 * @throws NullPointerException if either parameter is null 089 */ 090 public OutboundSmartClient(@Nonnull RestClient theRestClient, @Nonnull String theSmartRootUrl) { 091 myRestClient = Objects.requireNonNull(theRestClient, "theRestClient must not be null") 092 .mutate() 093 .configureMessageConverters(ClientMessageConverters::configure) 094 .build(); 095 mySmartRootUrl = Objects.requireNonNull(theSmartRootUrl, "theSmartRootUrl must not be null"); 096 } 097 /** 098 * Exchanges an authorization code for an access token with flexible client authentication. 099 * 100 * <p>This method provides full control over how the client secret is transmitted to the 101 * authorization server. It supports both HTTP Basic authentication (in the Authorization header) 102 * and form parameter authentication (in the request body).</p> 103 * 104 * <p>The method constructs a form-encoded POST request to the token endpoint with the 105 * authorization code and other required parameters. The response is parsed to extract 106 * the access token.</p> 107 * 108 * @param theClientId the OAuth 2.0 client identifier. Must not be null. 109 * @param theClientSecret the client secret. If null, no client authentication is performed. 110 * @param theCode the authorization code to exchange. Must not be null. 111 * @param secret_as_param if true, the client secret is sent as a form parameter (client_secret); 112 * if false, the client secret is sent via HTTP Basic authentication 113 * in the Authorization header. This parameter is ignored if theClientSecret is null. 114 * @return the access token as a string 115 * @throws IOException if the token exchange request fails or if the response cannot be parsed 116 * @see <a href="https://tools.ietf.org/html/rfc6749#section-4.1.3">RFC 6749 Section 4.1.3</a> 117 */ 118 public String exchangeCodeWithSecret( 119 String theClientId, String theClientSecret, String theCode, String theRedirectUri, boolean secret_as_param) throws IOException { 120 121 // Validate required parameters 122 if (theClientId == null || theClientId.trim().isEmpty()) { 123 throw new IllegalArgumentException("theClientId must not be null or empty"); 124 } 125 if (theCode == null || theCode.trim().isEmpty()) { 126 throw new IllegalArgumentException("theCode must not be null or empty"); 127 } 128 129 // Build form data string directly 130 String formData = "code=" + theCode + 131 "&grant_type=authorization_code" + 132 "&redirect_uri="+ URLEncoder.encode(theRedirectUri) + 133 "&client_id=" + theClientId; 134 135 // Build the request 136 RestClient.RequestBodySpec requestSpec = myRestClient 137 .post() 138 .uri("/oauth/token") 139 .contentType(MediaType.APPLICATION_FORM_URLENCODED); 140 141 // Add authorization header if needed 142 if (isNotBlank(theClientSecret)) { 143 if (secret_as_param) { 144 formData += "&client_secret=" + theClientSecret; 145 } else { 146 String auth = "Basic " + Base64.encodeBase64String( 147 (theClientId + ":" + theClientSecret).getBytes(StandardCharsets.UTF_8)); 148 requestSpec.header("Authorization", auth); 149 } 150 } 151 152 // Execute the request and process response 153 String respString = requestSpec 154 .body(formData) 155 .retrieve() 156 .body(String.class); 157 158 ourLog.debug("Resp: {}", respString); 159 160 JsonObject respObj = new Gson().fromJson(respString, JsonObject.class); 161 162 // Safely extract access_token 163 JsonElement accessTokenElement = respObj.get("access_token"); 164 if (accessTokenElement == null || accessTokenElement.isJsonNull()) { 165 throw new IOException("access_token not found in response"); 166 } 167 return accessTokenElement.getAsString(); 168 } 169 170 /** 171 * Refreshes an access token using a refresh token. 172 * 173 * <p>When an access token expires, a refresh token (if available) can be used to obtain 174 * a new access token without requiring the user to re-authorize the application. 175 * This method performs the token refresh operation.</p> 176 * 177 * <p>The authorization server may issue a new refresh token along with the new access token, 178 * and the old refresh token should be considered invalid.</p> 179 * 180 * @param theClientId the OAuth 2.0 client identifier. Must match the original client that 181 * obtained the refresh token. Must not be null or empty. 182 * @param theClientSecret the OAuth 2.0 client secret, sent with HTTP Basic authentication. May be 183 * null for a public client, which sends {@code client_id} as a form 184 * parameter instead. 185 * @param theRefreshToken the refresh token obtained from a previous token request. 186 * Must not be null or empty. 187 * @return JSON document as a string containing the new access token and potentially a new 188 * refresh token. Typical response includes: access_token, token_type, expires_in, 189 * refresh_token (optional), scope. {@link AccessTokenResponse#fromJson(JsonNode)} reads it. 190 * @throws IllegalArgumentException if required parameters are null or empty 191 * @see <a href="https://tools.ietf.org/html/rfc6749#section-6">RFC 6749 Section 6</a> 192 */ 193 @Nonnull 194 public String refreshToken( 195 @Nonnull String theClientId, 196 @Nullable String theClientSecret, 197 @Nonnull String theRefreshToken) { 198 requireNotBlank(theClientId, "theClientId"); 199 requireNotBlank(theRefreshToken, "theRefreshToken"); 200 201 MultiValueMap<String, String> form = new LinkedMultiValueMap<>(); 202 form.add("grant_type", "refresh_token"); 203 form.add("refresh_token", theRefreshToken); 204 if (theClientSecret == null) { 205 form.add("client_id", theClientId); 206 } 207 return Objects.requireNonNull( 208 postForm("oauth/token", form, theClientSecret == null ? null : theClientId, theClientSecret) 209 .body(String.class)); 210 } 211 212 /** 213 * Obtains an access token with the client credentials grant, authenticating with a client 214 * secret sent via HTTP Basic authentication. 215 * 216 * @param theClientId the OAuth 2.0 client identifier 217 * @param theClientSecret the client secret 218 * @param theScopes the scopes to request; none leaves the {@code scope} parameter out 219 * @return the token endpoint's response 220 * @see <a href="https://tools.ietf.org/html/rfc6749#section-4.4">RFC 6749 Section 4.4</a> 221 */ 222 @Nonnull 223 public AccessTokenResponse clientCredentials( 224 @Nonnull String theClientId, @Nonnull String theClientSecret, @Nullable String... theScopes) { 225 requireNotBlank(theClientId, "theClientId"); 226 requireNotBlank(theClientSecret, "theClientSecret"); 227 228 MultiValueMap<String, String> form = new LinkedMultiValueMap<>(); 229 form.add("grant_type", "client_credentials"); 230 addScope(form, theScopes); 231 return tokenResponse(postForm("oauth/token", form, theClientId, theClientSecret)); 232 } 233 234 /** 235 * Obtains an access token with the client credentials grant, authenticating with a signed JWT 236 * client assertion, as SMART Backend Services clients do. This client only transports the 237 * assertion: building and signing it is the caller's job. 238 * 239 * @param theClientId the OAuth 2.0 client identifier, sent as {@code client_id} 240 * @param theSignedJwtAssertion the signed JWT, sent as {@code client_assertion} 241 * @param theScopes the scopes to request; none leaves the {@code scope} parameter out 242 * @return the token endpoint's response 243 * @see <a href="https://datatracker.ietf.org/doc/html/rfc7523#section-2.2">RFC 7523 Section 2.2</a> 244 */ 245 @Nonnull 246 public AccessTokenResponse clientCredentialsWithAssertion( 247 @Nonnull String theClientId, @Nonnull String theSignedJwtAssertion, @Nullable String... theScopes) { 248 requireNotBlank(theClientId, "theClientId"); 249 requireNotBlank(theSignedJwtAssertion, "theSignedJwtAssertion"); 250 251 MultiValueMap<String, String> form = new LinkedMultiValueMap<>(); 252 form.add("grant_type", "client_credentials"); 253 form.add("client_id", theClientId); 254 form.add("client_assertion_type", CLIENT_ASSERTION_TYPE_JWT_BEARER); 255 form.add("client_assertion", theSignedJwtAssertion); 256 addScope(form, theScopes); 257 return tokenResponse(postForm("oauth/token", form, null, null)); 258 } 259 260 @Nonnull 261 public AccessTokenResponse jwtBearerWithAssertion( 262 @Nonnull String theAuthorizationGrant, @Nonnull String theSignedJwtAssertion, @Nullable String... theScopes) { 263 requireNotBlank(theAuthorizationGrant, "theAuthorizationGrant"); 264 requireNotBlank(theSignedJwtAssertion, "theSignedJwtAssertion"); 265 266 MultiValueMap<String, String> form = new LinkedMultiValueMap<>(); 267 form.add("grant_type", GRANT_TYPE_JWT_BEARER); 268 form.add("assertion", theAuthorizationGrant); 269 form.add("client_assertion_type", CLIENT_ASSERTION_TYPE_JWT_BEARER); 270 form.add("client_assertion", theSignedJwtAssertion); 271 addScope(form, theScopes); 272 return tokenResponse(postForm("oauth/token", form, null, null)); 273 } 274 275 /** 276 * Asks the server whether a token is active, authenticating as a client with HTTP Basic 277 * authentication. 278 * 279 * @param theToken the access or refresh token to introspect 280 * @param theClientId the client to authenticate as 281 * @param theClientSecret that client's secret 282 * @return the introspection response, whose {@code active} member says whether the token is live 283 * @see <a href="https://datatracker.ietf.org/doc/html/rfc7662">RFC 7662</a> 284 */ 285 @Nonnull 286 public JsonNode introspect( 287 @Nonnull String theToken, @Nonnull String theClientId, @Nonnull String theClientSecret) { 288 requireNotBlank(theToken, "theToken"); 289 requireNotBlank(theClientId, "theClientId"); 290 requireNotBlank(theClientSecret, "theClientSecret"); 291 292 MultiValueMap<String, String> form = new LinkedMultiValueMap<>(); 293 form.add("token", theToken); 294 return Objects.requireNonNull( 295 postForm("oauth/token/introspect", form, theClientId, theClientSecret).body(JsonNode.class)); 296 } 297 298 /** 299 * Fetches the server's OpenID Connect discovery document. 300 * 301 * @return the contents of {@code .well-known/openid-configuration} 302 */ 303 @Nonnull 304 public JsonNode openIdConfiguration() { 305 return Objects.requireNonNull( 306 myRestClient.get().uri(".well-known/openid-configuration").retrieve().body(JsonNode.class)); 307 } 308 309 private RestClient.ResponseSpec postForm( 310 String theUri, 311 MultiValueMap<String, String> theForm, 312 @Nullable String theBasicUser, 313 @Nullable String theBasicPassword) { 314 return myRestClient 315 .post() 316 .uri(theUri) 317 .contentType(MediaType.APPLICATION_FORM_URLENCODED) 318 .accept(MediaType.APPLICATION_JSON) 319 .headers(headers -> { 320 if (theBasicUser != null && theBasicPassword != null) { 321 headers.set(HttpHeaders.AUTHORIZATION, basicAuth(theBasicUser, theBasicPassword)); 322 } 323 }) 324 .body(theForm) 325 .retrieve(); 326 } 327 328 private static AccessTokenResponse tokenResponse(RestClient.ResponseSpec theResponse) { 329 return AccessTokenResponse.fromJson(Objects.requireNonNull(theResponse.body(JsonNode.class))); 330 } 331 332 private void addScope(MultiValueMap<String, String> theForm, String[] theScopes) { 333 if (theScopes != null && theScopes.length > 0) { 334 theForm.add("scope", buildScopeString(theScopes)); 335 } 336 } 337 338 private static String basicAuth(String theUser, String thePassword) { 339 return "Basic " + Base64.encodeBase64String((theUser + ":" + thePassword).getBytes(StandardCharsets.UTF_8)); 340 } 341 342 private static void requireNotBlank(String theValue, String theName) { 343 if (theValue == null || theValue.trim().isEmpty()) { 344 throw new IllegalArgumentException(theName + " must not be null or empty"); 345 } 346 } 347 348 /** 349 * Performs the complete OAuth 2.0 authorization code flow including user login. 350 * 351 * <p>This method orchestrates the entire authorization code flow by:</p> 352 * <ol> 353 * <li>Obtaining an authorization code through the authorization endpoint</li> 354 * <li>Exchanging the authorization code for an access token</li> 355 * </ol> 356 * 357 * <p>The method handles both confidential clients (with secret) and public clients (without secret).</p> 358 * 359 * @param clientId the OAuth 2.0 client identifier. Must not be null. 360 * @param redirectUri the redirect URI for receiving the authorization code. Must not be null. 361 * @param theScopes array of requested OAuth 2.0 scopes. Must not be null. 362 * @param state the state parameter for CSRF protection. May be null. 363 * @param theSecret the client secret. If null, the client is treated as public. 364 * @return the access token as a string 365 * @throws IOException if any HTTP communication or response parsing fails 366 * @see #getAuthorizationCode(String, String, String[], String, String, String) 367 * @see #exchangeCode(String, String, String) 368 * @see #exchangeCodeWithSecret(String, String,String, String, boolean) 369 */ 370 public String performAuthorizationCodeFlow(String clientId, String redirectUri, String[] theScopes, String state, String theSecret, String theUsername, String thePassword) throws IOException { 371 String code = getAuthorizationCode(clientId, redirectUri, theScopes, state, theUsername, thePassword); 372 String token; 373 if (theSecret != null) { 374 token = exchangeCodeWithSecret(clientId, theSecret, code, redirectUri, true); 375 } else { 376 token = exchangeCode(clientId, code, redirectUri); 377 } 378 return token; 379 } 380 381 /** 382 * Attempts to access an authorization URL and handles the redirect to the login screen. 383 * 384 * <p>This method makes a GET request to the authorization endpoint and expects to receive 385 * a redirect response (302/303) that points to the login screen. This is part of the normal 386 * OAuth 2.0 flow when the user is not yet authenticated.</p> 387 * 388 * @param theRequestUrl the authorization URL to request. Must not be null. 389 * @return the login screen URL extracted from the Location header of the redirect response 390 * @throws IOException if the request URL doesn't redirect to the expected signin location 391 * @throws RestClientException if the response is not a redirect (302/303) or if the Location header is missing 392 */ 393 public String authorizeBeforeLogin(String theRequestUrl) throws IOException { 394 String signinLocation =""; 395 396 ResponseEntity<String> retrieve = myRestClient 397 .get() 398 .uri(theRequestUrl) 399 .retrieve().toEntity(String.class); 400 401 if (retrieve.getStatusCode().value() == 302 || retrieve.getStatusCode().value() == 303) { 402 URI uri = retrieve.getHeaders().getLocation(); 403 if (uri != null) { 404 signinLocation = uri.toString(); 405 if (!signinLocation.startsWith(mySmartRootUrl + "/signin")) { 406 throw new IOException("Expected redirect to signin but got: " + signinLocation); 407 } 408 } 409 } else { 410 throw new RestClientException("Expected 3XX redirect but got " + retrieve.getStatusCode()); 411 } 412 return signinLocation; 413 } 414 /** 415 * Obtains an authorization code through the OAuth 2.0 authorization flow. 416 * 417 * <p>This method performs the first part of the authorization code flow:</p> 418 * <ol> 419 * <li>Constructs the authorization URL with the provided parameters</li> 420 * <li>Follows redirects to the login screen</li> 421 * <li>Performs login</li> 422 * <li>Completes the authorization and extracts the authorization code</li> 423 * </ol> 424 * 425 * <p>The authorization code can then be exchanged for tokens using 426 * {@link #exchangeCode(String, String,String )} or related methods.</p> 427 * 428 * @param clientId the OAuth 2.0 client identifier. Must not be null. 429 * @param redirectUri the redirect URI for receiving the authorization code. Must not be null. 430 * @param theScopes array of requested OAuth 2.0 scopes. Must not be null. 431 * @param state the state parameter for CSRF protection. May be null. 432 * @return the authorization code as a string 433 * @throws IOException if any HTTP communication fails or if the authorization code cannot be extracted 434 * @see #performAuthorizationCodeFlow(String, String, String[], String, String, String, String) 435 */ 436 public String getAuthorizationCode(String clientId, String redirectUri, String[] theScopes, String state, String theUsername, String thePassword) throws IOException { 437 String scopes = buildScopeString(theScopes); 438 439 String requestUrl = UriComponentsBuilder.fromPath("/oauth/authorize") 440 .queryParam("response_type", "code") 441 .queryParam("scope", scopes) 442 .queryParam("client_id", clientId) 443 .queryParam("state", state) 444 .queryParam("redirect_uri", redirectUri) 445 .toUriString(); 446 447 //First, attempt the authorize call, and get bounced. 448 authorizeBeforeLogin(requestUrl); 449 450 String nextUrl = loginWithPassword(theUsername ,thePassword); 451 return authorizeAfterLogin(nextUrl); 452 } 453 454 /** 455 * Builds a space-separated scope string from an array of scopes. 456 * 457 * <p>This utility method converts an array of OAuth 2.0 scope strings into a single 458 * space-separated string as required by the OAuth 2.0 specification.</p> 459 * 460 * @param theScopes array of OAuth 2.0 scopes. Must not be null. 461 * @return space-separated scope string, or empty string if no scopes provided 462 */ 463 public String buildScopeString(String[] theScopes) { 464 List<String> scopeList = Arrays.stream(theScopes).toList(); 465 String scopes = ""; 466 467 if (!scopeList.isEmpty()) { 468 scopes = String.join(" ", scopeList); 469 } 470 return scopes; 471 } 472 473 /** 474 * Exchanges an authorization code for an access token (public client). 475 * 476 * <p>This is a convenience method for public clients that don't have a client secret. 477 * It delegates to {@link #exchangeCodeWithSecret(String, String, String, String)} with 478 * a null client secret.</p> 479 * 480 * @param theClientId the OAuth 2.0 client identifier. Must not be null. 481 * @param theCode the authorization code to exchange. Must not be null. 482 * @return the access token as a string 483 * @throws IOException if the token exchange request fails 484 * @see #exchangeCodeWithSecret(String, String, String, String) 485 */ 486 public String exchangeCode(String theClientId, String theCode, String theRedirectUri) throws IOException { 487 return exchangeCodeWithSecret(theClientId, null, theCode, theRedirectUri); 488 } 489 490 /** 491 * Exchanges an authorization code for an access token using HTTP Basic authentication. 492 * 493 * <p>This is a convenience method that delegates to 494 * {@link #exchangeCodeWithSecret(String, String, String, String, boolean)} with 495 * the secret_as_param flag set to false, meaning the client secret will be sent 496 * in the Authorization header using HTTP Basic authentication.</p> 497 * 498 * @param theClientId the OAuth 2.0 client identifier. Must not be null. 499 * @param theClientSecret the client secret. If null, no authentication is performed. 500 * @param theCode the authorization code to exchange. Must not be null. 501 * @return the access token as a string 502 * @throws IOException if the token exchange request fails 503 * @see #exchangeCodeWithSecret(String, String,String, String, boolean) 504 */ 505 public String exchangeCodeWithSecret(String theClientId, String theClientSecret, String theCode, String theRedirectUri) 506 throws IOException { 507 return exchangeCodeWithSecret(theClientId, theClientSecret, theCode,theRedirectUri, false); 508 } 509 510 /** 511 * Completes the authorization step and extracts the authorization code from the redirect. 512 * 513 * <p>This method makes a request to the authorization URL (after user login) and expects 514 * to receive a redirect response containing the authorization code. The authorization code 515 * is extracted from the callback URL in the Location header.</p> 516 * 517 * @param theAuthorizeUrl the authorization URL to request (typically received after login). Must not be null. 518 * @return the authorization code extracted from the redirect URL 519 * @throws IOException if the authorization code cannot be found in the redirect URL 520 * @throws RestClientException if the response is not a redirect (302/303) or if the Location header is missing 521 * @see #extractCodeFromUrl(String) 522 */ 523 public String authorizeAfterLogin(String theAuthorizeUrl) throws IOException { 524 String code; 525 ResponseEntity<String> resp = myRestClient 526 .get() 527 .uri(theAuthorizeUrl) 528 .retrieve() 529 .toEntity(String.class); 530 theAuthorizeUrl = expectRedirectAndGetLocationHeaderValue(resp); 531 code = extractCodeFromUrl(theAuthorizeUrl); 532 return code; 533 } 534 535 private String expectRedirectAndGetLocationHeaderValue(ResponseEntity<String> resp) { 536 if (!(resp.getStatusCode().value() == 302 || resp.getStatusCode().value() == 303)) { 537 throw new RestClientException("Expected redirect response (302/303) but got: " + resp.getStatusCode().value()); 538 } 539 URI uri = resp.getHeaders().getLocation(); 540 if (uri != null) { 541 return uri.toString(); 542 } else { 543 throw new RestClientException("During redirect, the Location header was missing!"); 544 } 545 } 546 547 /** 548 * Extracts the authorization code from a callback URL. 549 * 550 * <p>This utility method parses the authorization code from the "code" query parameter 551 * in an OAuth 2.0 callback URL. This is typically used when processing the redirect 552 * response from the authorization server.</p> 553 * 554 * @param theUrl the callback URL containing the authorization code parameter. Must not be null. 555 * @return the authorization code value 556 * @throws IOException if the authorization code parameter is not found in the URL 557 */ 558 public static String extractCodeFromUrl(String theUrl) throws IOException { 559 if (theUrl == null || theUrl.trim().isEmpty()) { 560 throw new IllegalArgumentException("theUrl must not be null or empty"); 561 } 562 563 String code; 564 int start = theUrl.indexOf("code="); 565 if (start == -1) { 566 throw new IOException("Could not find authorization code in redirect URL: " + theUrl); 567 } 568 569 int codeStart = start + "code=".length(); 570 int end = theUrl.indexOf('&', start); 571 if (end == -1) { 572 // No more parameters after code, use end of string 573 end = theUrl.length(); 574 } 575 576 code = theUrl.substring(codeStart, end); 577 return code; 578 } 579 580 /** 581 * Fetches the login screen and extracts the CSRF token. 582 * 583 * <p>This method retrieves the SMART login page and parses the HTML to extract 584 * the CSRF token required for form-based authentication. The CSRF token is 585 * embedded in a hidden form field and is required to prevent cross-site request forgery attacks.</p> 586 * 587 * @return the CSRF token extracted from the login screen HTML 588 * @throws IOException if an error occurs while fetching the login screen or if the expected 589 * login page content is not found 590 * @see WebTestUtil#extractCsrfToken(String) 591 */ 592 private String fetchCsrfTokenFromLoginScreen() throws IOException { 593 String respString = myRestClient 594 .get() 595 .uri("/signin") 596 .retrieve() 597 .body(String.class); 598 599 ourLog.debug("Response: {}", respString); 600 if (respString == null || !respString.contains("<title>Login to SMART Application</title>")) { 601 throw new IOException("Response does not contain expected login page title"); 602 } 603 604 return WebTestUtil.extractCsrfToken(respString); 605 } 606 607 /** 608 * Performs form-based login with username and password credentials. 609 * 610 * <p>This method handles the complete login process:</p> 611 * <ol> 612 * <li>Fetches the login page and extracts the CSRF token</li> 613 * <li>Submits the login form with credentials and CSRF token</li> 614 * <li>Follows the redirect response to get the next URL in the flow</li> 615 * </ol> 616 * 617 * @param theUser the username for authentication. Must not be null. 618 * @param thePassword the password for authentication. Must not be null. 619 * @return the URL to redirect to after successful login (typically the authorization endpoint) 620 * @throws IOException if the login request fails or if the expected redirect response is not received 621 * @throws RestClientException if the HTTP response status is not a redirect (302/303) 622 */ 623 public String loginWithPassword(String theUser, String thePassword) throws IOException { 624 String csrfToken = fetchCsrfTokenFromLoginScreen(); 625 String formData = "username=" + theUser + "&password=" + thePassword + "&_csrf=" + csrfToken; 626 627 ResponseEntity<String> response = myRestClient 628 .post() 629 .uri("/authenticate") 630 .contentType(MediaType.APPLICATION_FORM_URLENCODED) 631 .body(formData) 632 .retrieve() 633 .toEntity(String.class); 634 635 return expectRedirectAndGetLocationHeaderValue(response); 636 } 637}