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}