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