DefaultConsentPage.java

/*
 * Copyright 2004-present the original author or authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.springframework.security.oauth2.server.authorization.web;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.oauth2.core.oidc.OidcScopes;

/**
 * For internal use only.
 *
 * @author Joe Grandja
 */
final class DefaultConsentPage {

	private static final MediaType TEXT_HTML_UTF8 = new MediaType("text", "html", StandardCharsets.UTF_8);

	private DefaultConsentPage() {
	}

	static void displayConsent(HttpServletRequest request, HttpServletResponse response, String clientId,
			Authentication principal, Set<String> requestedScopes, Set<String> authorizedScopes, String state,
			Map<String, String> additionalParameters) throws IOException {

		String consentPage = generateConsentPage(request, clientId, principal, requestedScopes, authorizedScopes, state,
				additionalParameters);
		response.setContentType(TEXT_HTML_UTF8.toString());
		response.setContentLength(consentPage.getBytes(StandardCharsets.UTF_8).length);
		response.getWriter().write(consentPage);
	}

	private static String generateConsentPage(HttpServletRequest request, String clientId, Authentication principal,
			Set<String> requestedScopes, Set<String> authorizedScopes, String state,
			Map<String, String> additionalParameters) {
		Set<String> scopesToAuthorize = new HashSet<>();
		Set<String> scopesPreviouslyAuthorized = new HashSet<>();
		for (String scope : requestedScopes) {
			if (authorizedScopes.contains(scope)) {
				scopesPreviouslyAuthorized.add(scope);
			}
			else if (!scope.equals(OidcScopes.OPENID)) {
				// openid scope does not require consent
				scopesToAuthorize.add(scope);
			}
		}

		// https://datatracker.ietf.org/doc/html/rfc8628#section-3.3.1
		// The server SHOULD display
		// the "user_code" to the user and ask them to verify that it matches
		// the "user_code" being displayed on the device to confirm they are
		// authorizing the correct device.
		String userCode = additionalParameters.get(OAuth2ParameterNames.USER_CODE);

		// @formatter:off
		StringBuilder builder = new StringBuilder();
		builder.append("<!DOCTYPE html>");
		builder.append("<html lang=\"en\">");
		builder.append("<head>");
		builder.append("    <meta charset=\"utf-8\">");
		builder.append("    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, shrink-to-fit=no\">");
		builder.append("    <link rel=\"stylesheet\" href=\"https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css\" integrity=\"sha384-JcKb8q3iqJ61gNV9KGb8thSsNjpSL0n8PARn9HuZOnIxN0hoP+VmmDGMN5t9UJ0Z\" crossorigin=\"anonymous\">");
		builder.append("    <title>Consent required</title>");
		builder.append("	<script>");
		builder.append("		function cancelConsent() {");
		builder.append("			document.consent_form.reset();");
		builder.append("			document.consent_form.submit();");
		builder.append("		}");
		builder.append("	</script>");
		builder.append("</head>");
		builder.append("<body>");
		builder.append("<div class=\"container\">");
		builder.append("    <div class=\"py-5\">");
		builder.append("        <h1 class=\"text-center\">Consent required</h1>");
		builder.append("    </div>");
		builder.append("    <div class=\"row\">");
		builder.append("        <div class=\"col text-center\">");
		builder.append("            <p><span class=\"font-weight-bold text-primary\">" + clientId + "</span> wants to access your account <span class=\"font-weight-bold\">" + principal.getName() + "</span></p>");
		builder.append("        </div>");
		builder.append("    </div>");
		if (userCode != null) {
			builder.append("    <div class=\"row\">");
			builder.append("        <div class=\"col text-center\">");
			builder.append("            <p class=\"alert alert-warning\">You have provided the code <span class=\"font-weight-bold\">" + userCode + "</span>. Verify that this code matches what is shown on your device.</p>");
			builder.append("        </div>");
			builder.append("    </div>");
		}
		builder.append("    <div class=\"row pb-3\">");
		builder.append("        <div class=\"col text-center\">");
		builder.append("            <p>The following permissions are requested by the above app.<br/>Please review these and consent if you approve.</p>");
		builder.append("        </div>");
		builder.append("    </div>");
		builder.append("    <div class=\"row\">");
		builder.append("        <div class=\"col text-center\">");
		builder.append("            <form name=\"consent_form\" method=\"post\" action=\"" + request.getRequestURI() + "\">");
		builder.append("                <input type=\"hidden\" name=\"client_id\" value=\"" + clientId + "\">");
		builder.append("                <input type=\"hidden\" name=\"state\" value=\"" + state + "\">");
		if (userCode != null) {
			builder.append("                <input type=\"hidden\" name=\"user_code\" value=\"" + userCode + "\">");
		}

		for (String scope : scopesToAuthorize) {
			builder.append("                <div class=\"form-group form-check py-1\">");
			builder.append("                    <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" value=\"" + scope + "\" id=\"" + scope + "\">");
			builder.append("                    <label class=\"form-check-label\" for=\"" + scope + "\">" + scope + "</label>");
			builder.append("                </div>");
		}

		if (!scopesPreviouslyAuthorized.isEmpty()) {
			builder.append("                <p>You have already granted the following permissions to the above app:</p>");
			for (String scope : scopesPreviouslyAuthorized) {
				builder.append("                <div class=\"form-group form-check py-1\">");
				builder.append("                    <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" id=\"" + scope + "\" checked disabled>");
				builder.append("                    <label class=\"form-check-label\" for=\"" + scope + "\">" + scope + "</label>");
				builder.append("                </div>");
			}
		}

		builder.append("                <div class=\"form-group pt-3\">");
		builder.append("                    <button class=\"btn btn-primary btn-lg\" type=\"submit\" id=\"submit-consent\">Submit Consent</button>");
		builder.append("                </div>");
		builder.append("                <div class=\"form-group\">");
		builder.append("                    <button class=\"btn btn-link regular\" type=\"button\" onclick=\"cancelConsent();\" id=\"cancel-consent\">Cancel</button>");
		builder.append("                </div>");
		builder.append("            </form>");
		builder.append("        </div>");
		builder.append("    </div>");
		builder.append("    <div class=\"row pt-4\">");
		builder.append("        <div class=\"col text-center\">");
		builder.append("            <p><small>Your consent to provide access is required.<br/>If you do not approve, click Cancel, in which case no information will be shared with the app.</small></p>");
		builder.append("        </div>");
		builder.append("    </div>");
		builder.append("</div>");
		builder.append("</body>");
		builder.append("</html>");
		// @formatter:on

		return builder.toString();
	}

}