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}