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