001package ca.cdr.test.app.clients; 002/*- 003 * #%L 004 * Smile CDR - CDR 005 * %% 006 * Copyright (C) 2016 - 2025 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.MediaType; 022import org.springframework.http.ResponseEntity; 023import org.springframework.web.client.RestClient; 024import org.springframework.web.client.RestClientException; 025import org.springframework.web.util.UriComponentsBuilder; 026 027import java.io.IOException; 028import java.net.URI; 029import java.net.URLEncoder; 030import java.nio.charset.StandardCharsets; 031import java.util.Arrays; 032import java.util.List; 033import java.util.Objects; 034 035import static org.apache.commons.lang3.StringUtils.isNotBlank; 036 037// Created by Claude Sonnet 4 038 039/** 040 * A client for performing SMART on FHIR OAuth 2.0 authorization flows. 041 * 042 * <p>This client provides methods to interact with SMART on FHIR OAuth 2.0 endpoints 043 * and supports the following OAuth 2.0 flows:</p> 044 * <ul> 045 * <li><strong>Authorization Code Flow</strong> - For web applications requiring user consent</li> 046 * </ul> 047 * 048 * <p>The client handles HTTP communication with the SMART authorization server, 049 * form-based authentication, CSRF token management, and token exchange operations.</p> 050 * 051 * <h3>Thread Safety:</h3> 052 * <p>This client is not thread-safe. Each thread should use its own instance 053 * or external synchronization should be applied.</p> 054 * 055 * @author Claude Sonnet 4 056 * @see <a href="http://hl7.org/fhir/smart-app-launch/">SMART App Launch Framework</a> 057 * @see <a href="https://tools.ietf.org/html/rfc6749">OAuth 2.0 Authorization Framework</a> 058 */ 059public class OutboundSmartClient { 060 private static final Logger ourLog = LoggerFactory.getLogger(OutboundSmartClient.class); 061 062 private final RestClient myRestClient; 063 private final String mySmartRootUrl; 064 065 /** 066 * Creates a new OutboundSmartClient instance. 067 * 068 * @param theRestClient the REST client to use for HTTP communication with the SMART server. 069 * This client should be configured with appropriate timeouts, SSL settings, 070 * and cookie management as needed for the target SMART server. 071 * @param theSmartRootUrl the root URL of the SMART authorization server (e.g., "https://auth.example.com"). 072 * This should not include trailing slashes or specific endpoints. 073 * @throws NullPointerException if either parameter is null 074 */ 075 public OutboundSmartClient(@Nonnull RestClient theRestClient, @Nonnull String theSmartRootUrl) { 076 myRestClient = Objects.requireNonNull(theRestClient, "theRestClient must not be null"); 077 mySmartRootUrl = Objects.requireNonNull(theSmartRootUrl, "theSmartRootUrl must not be null"); 078 } 079 /** 080 * Exchanges an authorization code for an access token with flexible client authentication. 081 * 082 * <p>This method provides full control over how the client secret is transmitted to the 083 * authorization server. It supports both HTTP Basic authentication (in the Authorization header) 084 * and form parameter authentication (in the request body).</p> 085 * 086 * <p>The method constructs a form-encoded POST request to the token endpoint with the 087 * authorization code and other required parameters. The response is parsed to extract 088 * the access token.</p> 089 * 090 * @param theClientId the OAuth 2.0 client identifier. Must not be null. 091 * @param theClientSecret the client secret. If null, no client authentication is performed. 092 * @param theCode the authorization code to exchange. Must not be null. 093 * @param secret_as_param if true, the client secret is sent as a form parameter (client_secret); 094 * if false, the client secret is sent via HTTP Basic authentication 095 * in the Authorization header. This parameter is ignored if theClientSecret is null. 096 * @return the access token as a string 097 * @throws IOException if the token exchange request fails or if the response cannot be parsed 098 * @see <a href="https://tools.ietf.org/html/rfc6749#section-4.1.3">RFC 6749 Section 4.1.3</a> 099 */ 100 public String exchangeCodeWithSecret( 101 String theClientId, String theClientSecret, String theCode, String theRedirectUri, boolean secret_as_param) throws IOException { 102 103 // Validate required parameters 104 if (theClientId == null || theClientId.trim().isEmpty()) { 105 throw new IllegalArgumentException("theClientId must not be null or empty"); 106 } 107 if (theCode == null || theCode.trim().isEmpty()) { 108 throw new IllegalArgumentException("theCode must not be null or empty"); 109 } 110 111 // Build form data string directly 112 String formData = "code=" + theCode + 113 "&grant_type=authorization_code" + 114 "&redirect_uri="+ URLEncoder.encode(theRedirectUri) + 115 "&client_id=" + theClientId; 116 117 // Build the request 118 RestClient.RequestBodySpec requestSpec = myRestClient 119 .post() 120 .uri("/oauth/token") 121 .contentType(MediaType.APPLICATION_FORM_URLENCODED); 122 123 // Add authorization header if needed 124 if (isNotBlank(theClientSecret)) { 125 if (secret_as_param) { 126 formData += "&client_secret=" + theClientSecret; 127 } else { 128 String auth = "Basic " + Base64.encodeBase64String( 129 (theClientId + ":" + theClientSecret).getBytes(StandardCharsets.UTF_8)); 130 requestSpec.header("Authorization", auth); 131 } 132 } 133 134 // Execute the request and process response 135 String respString = requestSpec 136 .body(formData) 137 .retrieve() 138 .body(String.class); 139 140 ourLog.debug("Resp: {}", respString); 141 142 JsonObject respObj = new Gson().fromJson(respString, JsonObject.class); 143 144 // Safely extract access_token 145 JsonElement accessTokenElement = respObj.get("access_token"); 146 if (accessTokenElement == null || accessTokenElement.isJsonNull()) { 147 throw new IOException("access_token not found in response"); 148 } 149 return accessTokenElement.getAsString(); 150 } 151 152 /** 153 * Refreshes an access token using a refresh token. 154 * 155 * <p>When an access token expires, a refresh token (if available) can be used to obtain 156 * a new access token without requiring the user to re-authorize the application. 157 * This method performs the token refresh operation.</p> 158 * 159 * <p>The authorization server may issue a new refresh token along with the new access token, 160 * and the old refresh token should be considered invalid.</p> 161 * 162 * <p><strong>Note:</strong> This method is currently not implemented and will throw an 163 * {@link UnsupportedOperationException}.</p> 164 * 165 * @param theClientId the OAuth 2.0 client identifier. Must match the original client that 166 * obtained the refresh token. Must not be null or empty. 167 * @param theClientSecret the OAuth 2.0 client secret. Required for confidential clients. 168 * May be null for public clients, depending on server configuration. 169 * @param theRefreshToken the refresh token obtained from a previous token request. 170 * Must not be null or empty. 171 * @return JSON document as a string containing the new access token and potentially a new 172 * refresh token. Typical response includes: access_token, token_type, expires_in, 173 * refresh_token (optional), scope 174 * @throws UnsupportedOperationException always, as this method is not yet implemented 175 * @throws IllegalArgumentException if required parameters are null or empty 176 * @see <a href="https://tools.ietf.org/html/rfc6749#section-6">RFC 6749 Section 6</a> 177 */ 178 @Nonnull 179 public String refreshToken( 180 @Nonnull String theClientId, 181 @Nullable String theClientSecret, 182 @Nonnull String theRefreshToken) { 183 184 throw new UnsupportedOperationException("Not yet implemented"); 185 } 186 /** 187 * Performs the complete OAuth 2.0 authorization code flow including user login. 188 * 189 * <p>This method orchestrates the entire authorization code flow by:</p> 190 * <ol> 191 * <li>Obtaining an authorization code through the authorization endpoint</li> 192 * <li>Exchanging the authorization code for an access token</li> 193 * </ol> 194 * 195 * <p>The method handles both confidential clients (with secret) and public clients (without secret).</p> 196 * 197 * @param clientId the OAuth 2.0 client identifier. Must not be null. 198 * @param redirectUri the redirect URI for receiving the authorization code. Must not be null. 199 * @param theScopes array of requested OAuth 2.0 scopes. Must not be null. 200 * @param state the state parameter for CSRF protection. May be null. 201 * @param theSecret the client secret. If null, the client is treated as public. 202 * @return the access token as a string 203 * @throws IOException if any HTTP communication or response parsing fails 204 * @see #getAuthorizationCode(String, String, String[], String, String, String) 205 * @see #exchangeCode(String, String, String) 206 * @see #exchangeCodeWithSecret(String, String,String, String, boolean) 207 */ 208 public String performAuthorizationCodeFlow(String clientId, String redirectUri, String[] theScopes, String state, String theSecret, String theUsername, String thePassword) throws IOException { 209 String code = getAuthorizationCode(clientId, redirectUri, theScopes, state, theUsername, thePassword); 210 String token; 211 if (theSecret != null) { 212 token = exchangeCodeWithSecret(clientId, theSecret, code, redirectUri, true); 213 } else { 214 token = exchangeCode(clientId, code, redirectUri); 215 } 216 return token; 217 } 218 219 /** 220 * Attempts to access an authorization URL and handles the redirect to the login screen. 221 * 222 * <p>This method makes a GET request to the authorization endpoint and expects to receive 223 * a redirect response (302/303) that points to the login screen. This is part of the normal 224 * OAuth 2.0 flow when the user is not yet authenticated.</p> 225 * 226 * @param theRequestUrl the authorization URL to request. Must not be null. 227 * @return the login screen URL extracted from the Location header of the redirect response 228 * @throws IOException if the request URL doesn't redirect to the expected signin location 229 * @throws RestClientException if the response is not a redirect (302/303) or if the Location header is missing 230 */ 231 public String authorizeBeforeLogin(String theRequestUrl) throws IOException { 232 String signinLocation =""; 233 234 ResponseEntity<String> retrieve = myRestClient 235 .get() 236 .uri(theRequestUrl) 237 .retrieve().toEntity(String.class); 238 239 if (retrieve.getStatusCode().value() == 302 || retrieve.getStatusCode().value() == 303) { 240 URI uri = retrieve.getHeaders().getLocation(); 241 if (uri != null) { 242 signinLocation = uri.toString(); 243 if (!signinLocation.startsWith(mySmartRootUrl + "/signin")) { 244 throw new IOException("Expected redirect to signin but got: " + signinLocation); 245 } 246 } 247 } else { 248 throw new RestClientException("Expected 3XX redirect but got " + retrieve.getStatusCode()); 249 } 250 return signinLocation; 251 } 252 /** 253 * Obtains an authorization code through the OAuth 2.0 authorization flow. 254 * 255 * <p>This method performs the first part of the authorization code flow:</p> 256 * <ol> 257 * <li>Constructs the authorization URL with the provided parameters</li> 258 * <li>Follows redirects to the login screen</li> 259 * <li>Performs login</li> 260 * <li>Completes the authorization and extracts the authorization code</li> 261 * </ol> 262 * 263 * <p>The authorization code can then be exchanged for tokens using 264 * {@link #exchangeCode(String, String,String )} or related methods.</p> 265 * 266 * @param clientId the OAuth 2.0 client identifier. Must not be null. 267 * @param redirectUri the redirect URI for receiving the authorization code. Must not be null. 268 * @param theScopes array of requested OAuth 2.0 scopes. Must not be null. 269 * @param state the state parameter for CSRF protection. May be null. 270 * @return the authorization code as a string 271 * @throws IOException if any HTTP communication fails or if the authorization code cannot be extracted 272 * @see #performAuthorizationCodeFlow(String, String, String[], String, String, String, String) 273 */ 274 public String getAuthorizationCode(String clientId, String redirectUri, String[] theScopes, String state, String theUsername, String thePassword) throws IOException { 275 String scopes = buildScopeString(theScopes); 276 277 String requestUrl = UriComponentsBuilder.fromPath("/oauth/authorize") 278 .queryParam("response_type", "code") 279 .queryParam("scope", scopes) 280 .queryParam("client_id", clientId) 281 .queryParam("state", state) 282 .queryParam("redirect_uri", redirectUri) 283 .toUriString(); 284 285 //First, attempt the authorize call, and get bounced. 286 authorizeBeforeLogin(requestUrl); 287 288 String nextUrl = loginWithPassword(theUsername ,thePassword); 289 return authorizeAfterLogin(nextUrl); 290 } 291 292 /** 293 * Builds a space-separated scope string from an array of scopes. 294 * 295 * <p>This utility method converts an array of OAuth 2.0 scope strings into a single 296 * space-separated string as required by the OAuth 2.0 specification.</p> 297 * 298 * @param theScopes array of OAuth 2.0 scopes. Must not be null. 299 * @return space-separated scope string, or empty string if no scopes provided 300 */ 301 public String buildScopeString(String[] theScopes) { 302 List<String> scopeList = Arrays.stream(theScopes).toList(); 303 String scopes = ""; 304 305 if (!scopeList.isEmpty()) { 306 scopes = String.join(" ", scopeList); 307 } 308 return scopes; 309 } 310 311 /** 312 * Exchanges an authorization code for an access token (public client). 313 * 314 * <p>This is a convenience method for public clients that don't have a client secret. 315 * It delegates to {@link #exchangeCodeWithSecret(String, String, String, String)} with 316 * a null client secret.</p> 317 * 318 * @param theClientId the OAuth 2.0 client identifier. Must not be null. 319 * @param theCode the authorization code to exchange. Must not be null. 320 * @return the access token as a string 321 * @throws IOException if the token exchange request fails 322 * @see #exchangeCodeWithSecret(String, String, String, String) 323 */ 324 public String exchangeCode(String theClientId, String theCode, String theRedirectUri) throws IOException { 325 return exchangeCodeWithSecret(theClientId, null, theCode, theRedirectUri); 326 } 327 328 /** 329 * Exchanges an authorization code for an access token using HTTP Basic authentication. 330 * 331 * <p>This is a convenience method that delegates to 332 * {@link #exchangeCodeWithSecret(String, String, String, String, boolean)} with 333 * the secret_as_param flag set to false, meaning the client secret will be sent 334 * in the Authorization header using HTTP Basic authentication.</p> 335 * 336 * @param theClientId the OAuth 2.0 client identifier. Must not be null. 337 * @param theClientSecret the client secret. If null, no authentication is performed. 338 * @param theCode the authorization code to exchange. Must not be null. 339 * @return the access token as a string 340 * @throws IOException if the token exchange request fails 341 * @see #exchangeCodeWithSecret(String, String,String, String, boolean) 342 */ 343 public String exchangeCodeWithSecret(String theClientId, String theClientSecret, String theCode, String theRedirectUri) 344 throws IOException { 345 return exchangeCodeWithSecret(theClientId, theClientSecret, theCode,theRedirectUri, false); 346 } 347 348 /** 349 * Completes the authorization step and extracts the authorization code from the redirect. 350 * 351 * <p>This method makes a request to the authorization URL (after user login) and expects 352 * to receive a redirect response containing the authorization code. The authorization code 353 * is extracted from the callback URL in the Location header.</p> 354 * 355 * @param theAuthorizeUrl the authorization URL to request (typically received after login). Must not be null. 356 * @return the authorization code extracted from the redirect URL 357 * @throws IOException if the authorization code cannot be found in the redirect URL 358 * @throws RestClientException if the response is not a redirect (302/303) or if the Location header is missing 359 * @see #extractCodeFromUrl(String) 360 */ 361 public String authorizeAfterLogin(String theAuthorizeUrl) throws IOException { 362 String code; 363 ResponseEntity<String> resp = myRestClient 364 .get() 365 .uri(theAuthorizeUrl) 366 .retrieve() 367 .toEntity(String.class); 368 theAuthorizeUrl = expectRedirectAndGetLocationHeaderValue(resp); 369 code = extractCodeFromUrl(theAuthorizeUrl); 370 return code; 371 } 372 373 private String expectRedirectAndGetLocationHeaderValue(ResponseEntity<String> resp) { 374 if (!(resp.getStatusCode().value() == 302 || resp.getStatusCode().value() == 303)) { 375 throw new RestClientException("Expected redirect response (302/303) but got: " + resp.getStatusCode().value()); 376 } 377 URI uri = resp.getHeaders().getLocation(); 378 if (uri != null) { 379 return uri.toString(); 380 } else { 381 throw new RestClientException("During redirect, the Location header was missing!"); 382 } 383 } 384 385 /** 386 * Extracts the authorization code from a callback URL. 387 * 388 * <p>This utility method parses the authorization code from the "code" query parameter 389 * in an OAuth 2.0 callback URL. This is typically used when processing the redirect 390 * response from the authorization server.</p> 391 * 392 * @param theUrl the callback URL containing the authorization code parameter. Must not be null. 393 * @return the authorization code value 394 * @throws IOException if the authorization code parameter is not found in the URL 395 */ 396 public static String extractCodeFromUrl(String theUrl) throws IOException { 397 if (theUrl == null || theUrl.trim().isEmpty()) { 398 throw new IllegalArgumentException("theUrl must not be null or empty"); 399 } 400 401 String code; 402 int start = theUrl.indexOf("code="); 403 if (start == -1) { 404 throw new IOException("Could not find authorization code in redirect URL: " + theUrl); 405 } 406 407 int codeStart = start + "code=".length(); 408 int end = theUrl.indexOf('&', start); 409 if (end == -1) { 410 // No more parameters after code, use end of string 411 end = theUrl.length(); 412 } 413 414 code = theUrl.substring(codeStart, end); 415 return code; 416 } 417 418 /** 419 * Fetches the login screen and extracts the CSRF token. 420 * 421 * <p>This method retrieves the SMART login page and parses the HTML to extract 422 * the CSRF token required for form-based authentication. The CSRF token is 423 * embedded in a hidden form field and is required to prevent cross-site request forgery attacks.</p> 424 * 425 * @return the CSRF token extracted from the login screen HTML 426 * @throws IOException if an error occurs while fetching the login screen or if the expected 427 * login page content is not found 428 * @see WebTestUtil#extractCsrfToken(String) 429 */ 430 private String fetchCsrfTokenFromLoginScreen() throws IOException { 431 String respString = myRestClient 432 .get() 433 .uri("/signin") 434 .retrieve() 435 .body(String.class); 436 437 ourLog.debug("Response: {}", respString); 438 if (respString == null || !respString.contains("<title>Login to SMART Application</title>")) { 439 throw new IOException("Response does not contain expected login page title"); 440 } 441 442 return WebTestUtil.extractCsrfToken(respString); 443 } 444 445 /** 446 * Performs form-based login with username and password credentials. 447 * 448 * <p>This method handles the complete login process:</p> 449 * <ol> 450 * <li>Fetches the login page and extracts the CSRF token</li> 451 * <li>Submits the login form with credentials and CSRF token</li> 452 * <li>Follows the redirect response to get the next URL in the flow</li> 453 * </ol> 454 * 455 * @param theUser the username for authentication. Must not be null. 456 * @param thePassword the password for authentication. Must not be null. 457 * @return the URL to redirect to after successful login (typically the authorization endpoint) 458 * @throws IOException if the login request fails or if the expected redirect response is not received 459 * @throws RestClientException if the HTTP response status is not a redirect (302/303) 460 */ 461 public String loginWithPassword(String theUser, String thePassword) throws IOException { 462 String csrfToken = fetchCsrfTokenFromLoginScreen(); 463 String formData = "username=" + theUser + "&password=" + thePassword + "&_csrf=" + csrfToken; 464 465 ResponseEntity<String> response = myRestClient 466 .post() 467 .uri("/authenticate") 468 .contentType(MediaType.APPLICATION_FORM_URLENCODED) 469 .body(formData) 470 .retrieve() 471 .toEntity(String.class); 472 473 return expectRedirectAndGetLocationHeaderValue(response); 474 } 475}