package cz.muni.ics.oauth2.web.endpoint;

import cz.muni.ics.openid.connect.request.OAuth2RequestFactory;
import lombok.Getter;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.common.exceptions.ClientAuthenticationException;
import org.springframework.security.oauth2.common.exceptions.InvalidClientException;
import org.springframework.security.oauth2.common.exceptions.InvalidGrantException;
import org.springframework.security.oauth2.common.exceptions.InvalidRequestException;
import org.springframework.security.oauth2.common.exceptions.InvalidScopeException;
import org.springframework.security.oauth2.common.exceptions.OAuth2Exception;
import org.springframework.security.oauth2.common.exceptions.RedirectMismatchException;
import org.springframework.security.oauth2.common.exceptions.UnapprovedClientAuthenticationException;
import org.springframework.security.oauth2.common.exceptions.UnsupportedResponseTypeException;
import org.springframework.security.oauth2.common.exceptions.UserDeniedAuthorizationException;
import org.springframework.security.oauth2.common.util.OAuth2Utils;
import org.springframework.security.oauth2.provider.AuthorizationRequest;
import org.springframework.security.oauth2.provider.ClientDetails;
import org.springframework.security.oauth2.provider.ClientDetailsService;
import org.springframework.security.oauth2.provider.CompositeTokenGranter;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.OAuth2Request;
import org.springframework.security.oauth2.provider.OAuth2RequestValidator;
import org.springframework.security.oauth2.provider.TokenGranter;
import org.springframework.security.oauth2.provider.TokenRequest;
import org.springframework.security.oauth2.provider.approval.UserApprovalHandler;
import org.springframework.security.oauth2.provider.code.AuthorizationCodeServices;
import org.springframework.security.oauth2.provider.endpoint.RedirectResolver;
import org.springframework.security.oauth2.provider.endpoint.TokenEndpoint;
import org.springframework.security.oauth2.provider.error.WebResponseExceptionTranslator;
import org.springframework.security.oauth2.provider.implicit.ImplicitTokenRequest;
import org.springframework.stereotype.Controller;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.HttpSessionRequiredException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.SessionAttributes;
import org.springframework.web.bind.support.DefaultSessionAttributeStore;
import org.springframework.web.bind.support.SessionAttributeStore;
import org.springframework.web.bind.support.SessionStatus;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.View;
import org.springframework.web.servlet.view.RedirectView;
import org.springframework.web.util.UriComponents;
import org.springframework.web.util.UriComponentsBuilder;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URI;
import java.security.Principal;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

import static cz.muni.ics.oauth2.web.endpoint.AuthorizationEndpoint.GrantType.AUTHORIZATION_CODE;
import static cz.muni.ics.oauth2.web.endpoint.AuthorizationEndpoint.GrantType.HYBRID;
import static cz.muni.ics.oauth2.web.endpoint.AuthorizationEndpoint.GrantType.IMPLICIT;
import static cz.muni.ics.openid.connect.request.ConnectRequestParameters.CLIENT_ID;
import static cz.muni.ics.openid.connect.request.ConnectRequestParameters.REDIRECT_URI;
import static cz.muni.ics.openid.connect.request.ConnectRequestParameters.RESPONSE_TYPE;
import static cz.muni.ics.openid.connect.request.ConnectRequestParameters.SCOPE;
import static cz.muni.ics.openid.connect.request.ConnectRequestParameters.STATE;

/**
 * <p>
 * Implementation of the Authorization Endpoint from the OAuth2 specification. Accepts authorization requests, and
 * handles user approval if the grant type is authorization code. The tokens themselves are obtained from the
 * {@link TokenEndpoint Token Endpoint}, except in the implicit grant type (where they come from the Authorization
 * Endpoint via <code>response_type=token</code>.
 * </p>
 *
 * <p>
 * This endpoint should be secured so that it is only accessible to fully authenticated users (as a minimum requirement)
 * since it represents a request from a valid user to act on his or her behalf.
 * </p>
 *
 * <p>
 * @deprecated See the <a href="https://github.com/spring-projects/spring-security/wiki/OAuth-2.0-Migration-Guide">OAuth 2.0 Migration Guide</a> for Spring Security 5.
 *
 * @author Dave Syer
 * @author Vladimir Kryachko
 *
 */
@SessionAttributes({
        AuthorizationEndpoint.AUTHORIZATION_REQUEST_ATTR_NAME,
        AuthorizationEndpoint.ORIGINAL_AUTHORIZATION_REQUEST_ATTR_NAME
})
@Controller
@Slf4j
public class AuthorizationEndpoint extends AbstractEndpoint {

    public static final String ENDPOINT_INIT_URL = "/authorize";

    public static final String ENDPOINT_URL = "/auth/authorize";

    @Getter
    protected enum GrantType {
        AUTHORIZATION_CODE("authorization_code"),
        IMPLICIT("implicit"),
        HYBRID("hybrid");
        private final String value;

        GrantType(String value) {
            this.value = value;
        }

    }

    static final String AUTHORIZATION_REQUEST_ATTR_NAME = "authorizationRequest";

    static final String ORIGINAL_AUTHORIZATION_REQUEST_ATTR_NAME = "org.springframework.security.oauth2.provider.endpoint.AuthorizationEndpoint.ORIGINAL_AUTHORIZATION_REQUEST";

    private final AuthorizationCodeServices authorizationCodeServices;

    private final RedirectResolver redirectResolver;

    private final UserApprovalHandler userApprovalHandler;

    private final SessionAttributeStore sessionAttributeStore = new DefaultSessionAttributeStore();

    private final OAuth2RequestValidator oauth2RequestValidator;

    private final Object implicitLock = new Object();

    private final ClientDetailsService clientDetailsService;

    @Autowired
    public AuthorizationEndpoint(AuthorizationCodeServices authorizationCodeServices,
                                 RedirectResolver redirectResolver,
                                 UserApprovalHandler userApprovalHandler,
                                 OAuth2RequestValidator oauth2RequestValidator,
                                 WebResponseExceptionTranslator<OAuth2Exception> providerExceptionHandler,
                                 List<TokenGranter> tokenGranters,
                                 ClientDetailsService clientDetailsService,
                                 OAuth2RequestFactory oAuth2RequestFactory,
                                 ClientDetailsService clientDetailsService1)
    {
        this.authorizationCodeServices = authorizationCodeServices;
        this.redirectResolver = redirectResolver;
        this.userApprovalHandler = userApprovalHandler;
        this.oauth2RequestValidator = oauth2RequestValidator;
        this.clientDetailsService = clientDetailsService1;
        super.setProviderExceptionHandler(providerExceptionHandler);
        super.setTokenGranter(new TokenGranter() {
            private CompositeTokenGranter delegate;

            @Override
            public OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest) {
                if (delegate == null) {
                    delegate = new CompositeTokenGranter(tokenGranters);
                }
                return delegate.grant(grantType, tokenRequest);
            }
        });
        super.setClientDetailsService(clientDetailsService);
        super.setOAuth2RequestFactory(oAuth2RequestFactory);
    }

    @RequestMapping(value = ENDPOINT_INIT_URL)
    public RedirectView authorize(HttpServletRequest req, HttpServletResponse res) throws IOException {
        try {
            validateRequest(req);
        } catch (OAuth2Exception ex) {
            handleValidationException(ex, req, res);
            return null;
        }

        String redirect = ENDPOINT_URL + '?' + req.getQueryString();
        RedirectView view = new RedirectView(redirect);
        view.setContextRelative(true);
        log.debug("Authorization endpoint - {}: user is being redirected to to: {}", ENDPOINT_INIT_URL, redirect);
        return view;
    }

    @RequestMapping(value = ENDPOINT_URL)
    public ModelAndView authorize(HttpServletRequest req,
                                  Map<String, Object> model,
                                  SessionStatus sessionStatus,
                                  Principal principal)
    {
        Map<String, String> serializedParams = createSerializedParams(req.getParameterMap());
        AuthorizationRequest authorizationRequest = getOAuth2RequestFactory().createAuthorizationRequest(serializedParams);
        Set<String> responseTypes = authorizationRequest.getResponseTypes();

        if (!responseTypes.contains("token") && !responseTypes.contains("code")) {
            throw new UnsupportedResponseTypeException("Unsupported response types");
        }

        if (authorizationRequest.getClientId() == null) {
            throw new InvalidClientException("A client id must be provided");
        }

        try {

            if (!(principal instanceof Authentication) || !((Authentication) principal).isAuthenticated()) {
                throw new InsufficientAuthenticationException(
                        "User must be authenticated with Spring Security before authorization can be completed.");
            }

            ClientDetails client = getClientDetailsService().loadClientByClientId(authorizationRequest.getClientId());

            // The resolved redirect URI is either the redirect_uri from the parameters or the one from
            // clientDetails. Either way we need to store it on the AuthorizationRequest.
            String redirectUriParameter = authorizationRequest.getRequestParameters().get(OAuth2Utils.REDIRECT_URI);
            String resolvedRedirect = redirectResolver.resolveRedirect(redirectUriParameter, client);
            if (!StringUtils.hasText(resolvedRedirect)) {
                throw new RedirectMismatchException(
                        "A redirectUri must be either supplied or preconfigured in the ClientDetails");
            }
            authorizationRequest.setRedirectUri(resolvedRedirect);

            // We intentionally only validate the parameters requested by the client (ignoring any data that may have
            // been added to the request by the manager).
            oauth2RequestValidator.validateScope(authorizationRequest, client);

            // Some systems may allow for approval decisions to be remembered or approved by default. Check for
            // such logic here, and set the approved flag on the authorization request accordingly.
            authorizationRequest = userApprovalHandler.checkForPreApproval(authorizationRequest,
                    (Authentication) principal);
            // TODO: is this call necessary?
            boolean approved = userApprovalHandler.isApproved(authorizationRequest, (Authentication) principal);
            authorizationRequest.setApproved(approved);

            // Validation is all done, so we can check for auto approval...
            if (authorizationRequest.isApproved()) {
                if (responseTypes.contains("token")) {
                    return getImplicitGrantResponse(authorizationRequest);
                }
                if (responseTypes.contains("code")) {
                    return new ModelAndView(getAuthorizationCodeResponse(authorizationRequest,
                            (Authentication) principal));
                }
            }

            // Store authorizationRequest AND an immutable Map of authorizationRequest in session
            // which will be used to validate against in approveOrDeny()
            model.put(AUTHORIZATION_REQUEST_ATTR_NAME, authorizationRequest);
            model.put(ORIGINAL_AUTHORIZATION_REQUEST_ATTR_NAME, unmodifiableMap(authorizationRequest));

            return getUserApprovalPageResponse(model, authorizationRequest, (Authentication) principal);

        }
        catch (RuntimeException e) {
            sessionStatus.setComplete();
            throw e;
        }
    }

    Map<String, Object> unmodifiableMap(AuthorizationRequest authorizationRequest) {
        Map<String, Object> authorizationRequestMap = new HashMap<>();

        authorizationRequestMap.put(OAuth2Utils.CLIENT_ID, authorizationRequest.getClientId());
        authorizationRequestMap.put(OAuth2Utils.STATE, authorizationRequest.getState());
        authorizationRequestMap.put(OAuth2Utils.REDIRECT_URI, authorizationRequest.getRedirectUri());
        if (authorizationRequest.getResponseTypes() != null) {
            authorizationRequestMap.put(OAuth2Utils.RESPONSE_TYPE,
                    Set.copyOf(authorizationRequest.getResponseTypes()));
        }
        if (authorizationRequest.getScope() != null) {
            authorizationRequestMap.put(OAuth2Utils.SCOPE,
                    Set.copyOf(authorizationRequest.getScope()));
        }
        authorizationRequestMap.put("approved", authorizationRequest.isApproved());
        if (authorizationRequest.getResourceIds() != null) {
            authorizationRequestMap.put("resourceIds",
                    Set.copyOf(authorizationRequest.getResourceIds()));
        }
        if (authorizationRequest.getAuthorities() != null) {
            authorizationRequestMap.put("authorities",
                    Set.<GrantedAuthority>copyOf(authorizationRequest.getAuthorities()));
        }

        return Collections.unmodifiableMap(authorizationRequestMap);
    }

    @RequestMapping(value = ENDPOINT_URL, method = RequestMethod.POST, params = OAuth2Utils.USER_OAUTH_APPROVAL)
    public View approveOrDeny(@RequestParam Map<String, String> approvalParameters, Map<String, ?> model,
                              SessionStatus sessionStatus, Principal principal)
    {

        if (!(principal instanceof Authentication)) {
            sessionStatus.setComplete();
            throw new InsufficientAuthenticationException(
                    "User must be authenticated with Spring Security before authorizing an access token.");
        }

        AuthorizationRequest authorizationRequest = (AuthorizationRequest) model.get(AUTHORIZATION_REQUEST_ATTR_NAME);

        if (authorizationRequest == null) {
            sessionStatus.setComplete();
            throw new InvalidRequestException("Cannot approve uninitialized authorization request.");
        }

        // Check to ensure the Authorization Request was not modified during the user approval step
        @SuppressWarnings("unchecked")
        Map<String, Object> originalAuthorizationRequest = (Map<String, Object>) model.get(ORIGINAL_AUTHORIZATION_REQUEST_ATTR_NAME);
        if (isAuthorizationRequestModified(authorizationRequest, originalAuthorizationRequest)) {
            throw new InvalidRequestException("Changes were detected from the original authorization request.");
        }

        try {
            Set<String> responseTypes = authorizationRequest.getResponseTypes();

            authorizationRequest.setApprovalParameters(approvalParameters);
            authorizationRequest = userApprovalHandler.updateAfterApproval(authorizationRequest,
                    (Authentication) principal);
            boolean approved = userApprovalHandler.isApproved(authorizationRequest, (Authentication) principal);
            authorizationRequest.setApproved(approved);

            if (authorizationRequest.getRedirectUri() == null) {
                sessionStatus.setComplete();
                throw new InvalidRequestException("Cannot approve request when no redirect URI is provided.");
            }

            if (!authorizationRequest.isApproved()) {
                RedirectView redirectView = new RedirectView(getUnsuccessfulRedirect(authorizationRequest,
                        new UserDeniedAuthorizationException("User denied access"), responseTypes.contains("token")),
                        false, true, false);
                redirectView.setStatusCode(HttpStatus.SEE_OTHER);
                return redirectView;
            }

            if (responseTypes.contains("token")) {
                return getImplicitGrantResponse(authorizationRequest).getView();
            }

            return getAuthorizationCodeResponse(authorizationRequest, (Authentication) principal);
        }
        finally {
            sessionStatus.setComplete();
        }

    }

    private boolean isAuthorizationRequestModified(
            AuthorizationRequest authorizationRequest, Map<String, Object> originalAuthorizationRequest) {
        if (!ObjectUtils.nullSafeEquals(
                authorizationRequest.getClientId(),
                originalAuthorizationRequest.get(OAuth2Utils.CLIENT_ID))) {
            return true;
        }
        if (!ObjectUtils.nullSafeEquals(
                authorizationRequest.getState(),
                originalAuthorizationRequest.get(OAuth2Utils.STATE))) {
            return true;
        }
        if (!ObjectUtils.nullSafeEquals(
                authorizationRequest.getRedirectUri(),
                originalAuthorizationRequest.get(OAuth2Utils.REDIRECT_URI))) {
            return true;
        }
        if (!ObjectUtils.nullSafeEquals(
                authorizationRequest.getResponseTypes(),
                originalAuthorizationRequest.get(OAuth2Utils.RESPONSE_TYPE))) {
            return true;
        }
        if (!ObjectUtils.nullSafeEquals(
                authorizationRequest.getScope(),
                originalAuthorizationRequest.get(OAuth2Utils.SCOPE))) {
            return true;
        }
        if (!ObjectUtils.nullSafeEquals(
                authorizationRequest.isApproved(),
                originalAuthorizationRequest.get("approved"))) {
            return true;
        }
        if (!ObjectUtils.nullSafeEquals(
                authorizationRequest.getResourceIds(),
                originalAuthorizationRequest.get("resourceIds"))) {
            return true;
        }
        return !ObjectUtils.nullSafeEquals(
                authorizationRequest.getAuthorities(),
                originalAuthorizationRequest.get("authorities"));
    }

    // We need explicit approval from the user.
    private ModelAndView getUserApprovalPageResponse(Map<String, Object> model,
                                                     AuthorizationRequest authorizationRequest, Authentication principal) {
        String userApprovalPage = "forward:/oauth/confirm_access";
        if (log.isDebugEnabled()) {
            log.debug("Loading user approval page: " + userApprovalPage);
        }
        model.putAll(userApprovalHandler.getUserApprovalRequest(authorizationRequest, principal));
        return new ModelAndView(userApprovalPage, model);
    }

    // We can grant a token and return it with implicit approval.
    private ModelAndView getImplicitGrantResponse(AuthorizationRequest authorizationRequest) {
        try {
            TokenRequest tokenRequest = getOAuth2RequestFactory().createTokenRequest(authorizationRequest, "implicit");
            OAuth2Request storedOAuth2Request = getOAuth2RequestFactory().createOAuth2Request(authorizationRequest);
            OAuth2AccessToken accessToken = getAccessTokenForImplicitGrant(tokenRequest, storedOAuth2Request);
            if (accessToken == null) {
                throw new UnsupportedResponseTypeException("Unsupported response type: token");
            }
            setCacheControlHeaders();
            RedirectView redirectView = new RedirectView(appendAccessToken(authorizationRequest, accessToken), false, true,
                    false);
            redirectView.setStatusCode(HttpStatus.SEE_OTHER);
            return new ModelAndView(redirectView);
        }
        catch (OAuth2Exception e) {
            RedirectView redirectView = new RedirectView(getUnsuccessfulRedirect(authorizationRequest, e, true), false,
                    true, false);
            redirectView.setStatusCode(HttpStatus.SEE_OTHER);
            return new ModelAndView(redirectView);
        }
    }

    private OAuth2AccessToken getAccessTokenForImplicitGrant(TokenRequest tokenRequest,
                                                             OAuth2Request storedOAuth2Request) {
        OAuth2AccessToken accessToken;
        // These 1 method calls have to be atomic, otherwise the ImplicitGrantService can have a race condition where
        // one thread removes the token request before another has a chance to redeem it.
        synchronized (this.implicitLock) {
            accessToken = getTokenGranter().grant("implicit",
                    new ImplicitTokenRequest(tokenRequest, storedOAuth2Request));
        }
        return accessToken;
    }

    private View getAuthorizationCodeResponse(AuthorizationRequest authorizationRequest, Authentication authUser) {
        try {
            RedirectView redirectView = new RedirectView(getSuccessfulRedirect(authorizationRequest,
                    generateCode(authorizationRequest, authUser)), false, true, false);
            redirectView.setStatusCode(HttpStatus.SEE_OTHER);
            return redirectView;
        }
        catch (OAuth2Exception e) {
            RedirectView redirectView = new RedirectView(getUnsuccessfulRedirect(authorizationRequest, e, false), false, true, false);
            redirectView.setStatusCode(HttpStatus.SEE_OTHER);
            return redirectView;
        }
    }

    private String appendAccessToken(AuthorizationRequest authorizationRequest, OAuth2AccessToken accessToken) {

        Map<String, Object> vars = new LinkedHashMap<>();
        Map<String, String> keys = new HashMap<>();

        if (accessToken == null) {
            throw new InvalidRequestException("An implicit grant could not be made");
        }

        vars.put("access_token", accessToken.getValue());
        vars.put("token_type", accessToken.getTokenType());
        String state = authorizationRequest.getState();

        if (state != null) {
            vars.put("state", state);
        }
        Date expiration = accessToken.getExpiration();
        if (expiration != null) {
            long expires_in = (expiration.getTime() - System.currentTimeMillis()) / 1000;
            vars.put("expires_in", expires_in);
        }
        String originalScope = authorizationRequest.getRequestParameters().get(OAuth2Utils.SCOPE);
        if (originalScope == null || !OAuth2Utils.parseParameterList(originalScope).equals(accessToken.getScope())) {
            vars.put(OAuth2Utils.SCOPE, OAuth2Utils.formatParameterList(accessToken.getScope()));
        }
        Map<String, Object> additionalInformation = accessToken.getAdditionalInformation();
        for (String key : additionalInformation.keySet()) {
            Object value = additionalInformation.get(key);
            if (value != null) {
                keys.put("extra_" + key, key);
                vars.put("extra_" + key, value);
            }
        }
        // Do not include the refresh token (even if there is one)
        return append(authorizationRequest.getRedirectUri(), vars, keys, true);
    }

    private String generateCode(AuthorizationRequest authorizationRequest, Authentication authentication)
            throws AuthenticationException {

        try {

            OAuth2Request storedOAuth2Request = getOAuth2RequestFactory().createOAuth2Request(authorizationRequest);

            OAuth2Authentication combinedAuth = new OAuth2Authentication(storedOAuth2Request, authentication);

            return authorizationCodeServices.createAuthorizationCode(combinedAuth);

        }
        catch (OAuth2Exception e) {

            if (authorizationRequest.getState() != null) {
                e.addAdditionalInformation("state", authorizationRequest.getState());
            }

            throw e;

        }
    }

    private String getSuccessfulRedirect(AuthorizationRequest authorizationRequest, String authorizationCode) {

        if (authorizationCode == null) {
            throw new IllegalStateException("No authorization code found in the current request scope.");
        }

        Map<String, String> query = new LinkedHashMap<>();
        query.put("code", authorizationCode);

        String state = authorizationRequest.getState();
        if (state != null) {
            query.put("state", state);
        }

        return append(authorizationRequest.getRedirectUri(), query, false);
    }

    private String getUnsuccessfulRedirect(AuthorizationRequest authorizationRequest, OAuth2Exception failure,
                                           boolean fragment) {

        if (authorizationRequest == null || authorizationRequest.getRedirectUri() == null) {
            // we have no redirect for the user. very sad.
            throw new UnapprovedClientAuthenticationException("Authorization failure, and no redirect URI.", failure);
        }

        Map<String, String> query = new LinkedHashMap<>();

        query.put("error", failure.getOAuth2ErrorCode());
        query.put("error_description", failure.getMessage());

        if (authorizationRequest.getState() != null) {
            query.put("state", authorizationRequest.getState());
        }

        if (failure.getAdditionalInformation() != null) {
            query.putAll(failure.getAdditionalInformation());
        }

        return append(authorizationRequest.getRedirectUri(), query, fragment);

    }

    private String append(String base, Map<String, ?> query, boolean fragment) {
        return append(base, query, null, fragment);
    }

    private String append(String base, Map<String, ?> query, Map<String, String> keys, boolean fragment) {

        UriComponentsBuilder template = UriComponentsBuilder.newInstance();
        UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(base);
        URI redirectUri;
        try {
            // assume it's encoded to start with (if it came in over the wire)
            redirectUri = builder.build(true).toUri();
        }
        catch (Exception e) {
            // ... but allow client registrations to contain hard-coded non-encoded values
            redirectUri = builder.build().toUri();
            builder = UriComponentsBuilder.fromUri(redirectUri);
        }
        template.scheme(redirectUri.getScheme()).port(redirectUri.getPort()).host(redirectUri.getHost())
                .userInfo(redirectUri.getUserInfo()).path(redirectUri.getPath());

        if (fragment) {
            StringBuilder values = new StringBuilder();
            if (redirectUri.getFragment() != null) {
                String append = redirectUri.getFragment();
                values.append(append);
            }
            for (String key : query.keySet()) {
                if (values.length() > 0) {
                    values.append("&");
                }
                String name = key;
                if (keys != null && keys.containsKey(key)) {
                    name = keys.get(key);
                }
                values.append(name).append("={").append(key).append("}");
            }
            if (values.length() > 0) {
                template.fragment(values.toString());
            }
            UriComponents encoded = template.build().expand(query).encode();
            builder.fragment(encoded.getFragment());
        }
        else {
            for (String key : query.keySet()) {
                String name = key;
                if (keys != null && keys.containsKey(key)) {
                    name = keys.get(key);
                }
                template.queryParam(name, "{" + key + "}");
            }
            template.fragment(redirectUri.getFragment());
            UriComponents encoded = template.build().expand(query).encode();
            builder.query(encoded.getQuery());
        }

        return builder.build().toUriString();

    }

    @ExceptionHandler(OAuth2Exception.class)
    public ModelAndView handleOAuth2Exception(OAuth2Exception e, ServletWebRequest webRequest) throws Exception {
        log.info("Handling OAuth2 error: " + e.getSummary());
        return handleException(e, webRequest);
    }

    @ExceptionHandler(HttpSessionRequiredException.class)
    public ModelAndView handleHttpSessionRequiredException(HttpSessionRequiredException e, ServletWebRequest webRequest)
            throws Exception {
        log.info("Handling Session required error: " + e.getMessage());
        return handleException(new AccessDeniedException("Could not obtain authorization request from session", e),
                webRequest);
    }

    private ModelAndView handleException(Exception e, ServletWebRequest webRequest) throws Exception {

        ResponseEntity<OAuth2Exception> translate = getExceptionTranslator().translate(e);
        webRequest.getResponse().setStatus(translate.getStatusCode().value());

        String errorPage = "forward:/error";
        if (e instanceof ClientAuthenticationException || e instanceof RedirectMismatchException) {
            return new ModelAndView(errorPage, Collections.singletonMap("error", translate.getBody()));
        }

        AuthorizationRequest authorizationRequest;
        try {
            authorizationRequest = getAuthorizationRequestForError(webRequest);
            String requestedRedirectParam = authorizationRequest.getRequestParameters().get(OAuth2Utils.REDIRECT_URI);
            String requestedRedirect = redirectResolver.resolveRedirect(requestedRedirectParam,
                    getClientDetailsService().loadClientByClientId(authorizationRequest.getClientId()));
            authorizationRequest.setRedirectUri(requestedRedirect);
            String redirect = getUnsuccessfulRedirect(authorizationRequest, translate.getBody(), authorizationRequest
                    .getResponseTypes().contains("token"));
            RedirectView redirectView = new RedirectView(redirect, false, true, false);
            redirectView.setStatusCode(HttpStatus.SEE_OTHER);
            return new ModelAndView(redirectView);
        }
        catch (OAuth2Exception ex) {
            // If an AuthorizationRequest cannot be created from the incoming parameters it must be
            // an error. OAuth2Exception can be handled this way. Other exceptions will generate a standard 500
            // response.
            return new ModelAndView(errorPage, Collections.singletonMap("error", translate.getBody()));
        }

    }

    private AuthorizationRequest getAuthorizationRequestForError(ServletWebRequest webRequest) {
        // If it's already there then we are in the approveOrDeny phase and we can use the saved request
        AuthorizationRequest authorizationRequest = (AuthorizationRequest) sessionAttributeStore.retrieveAttribute(
                webRequest, AUTHORIZATION_REQUEST_ATTR_NAME);
        if (authorizationRequest != null) {
            return authorizationRequest;
        }

        Map<String, String> serializedParams = createSerializedParams(webRequest.getParameterMap());
        return getOAuth2RequestFactory().createAuthorizationRequest(serializedParams);
    }

    private void setCacheControlHeaders() {
        ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if (servletRequestAttributes != null) {
            HttpServletResponse servletResponse = servletRequestAttributes.getResponse();
            if (servletResponse != null) {
                servletResponse.setHeader(HttpHeaders.CACHE_CONTROL, "no-store");
                servletResponse.setHeader(HttpHeaders.PRAGMA, "no-cache");
            }
        }
    }

    private void handleValidationException(OAuth2Exception ex, HttpServletRequest req, HttpServletResponse res)
            throws IOException
    {
        String redirectUri = req.getParameter(REDIRECT_URI);
        if (!StringUtils.hasText(redirectUri)) {
            log.debug(
                    "Authorization endpoint: request did not pass validation, but do not have any redirect URI. " +
                            "Rethrowing the error as exception"
            );
            throw ex;
        }
        Map<String, String> params = new HashMap<>();
        params.put("error", ex.getOAuth2ErrorCode());
        if (StringUtils.hasText(ex.getMessage())) {
            params.put("error_description", ex.getMessage());
        }
        String state = req.getParameter(STATE);
        if (StringUtils.hasText(state)) {
            params.put(STATE, state);
        }

        UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(redirectUri);
        for (Map.Entry<String, String> e: params.entrySet()) {
            builder = builder.queryParam(e.getKey(), e.getValue());
        }
        String url = builder.toUriString();
        res.setStatus(ex.getHttpErrorCode());
        log.debug(
                "Authorization endpoint: request did not pass validation, returning error response to '{}'", url
        );

        res.sendRedirect(url);
    }

    private void validateRequest(HttpServletRequest req) {
        String clientId = req.getParameter(CLIENT_ID);
        if (!StringUtils.hasText(clientId)) {
            throw new InvalidRequestException("No client_id provided");
        }
        ClientDetails clientDetails = clientDetailsService.loadClientByClientId(clientId);
        if (clientDetails == null) {
            throw new InvalidClientException("Client not found");
        }

        String responseType = req.getParameter(RESPONSE_TYPE);
        GrantType grantType;
        if (!StringUtils.hasText(responseType)) {
            throw new InvalidRequestException("No response type provided");
        } else {
            boolean allowedGrant;
            Set<String> clientGrants = clientDetails.getAuthorizedGrantTypes();
            switch (responseType) {
                case "code":
                    allowedGrant = clientGrants.contains(AUTHORIZATION_CODE.getValue());
                    grantType = AUTHORIZATION_CODE;
                    break;
                case "id_token token":
                case "token id_token":
                    allowedGrant = clientGrants.contains(IMPLICIT.getValue());
                    grantType = IMPLICIT;
                    break;
                case "code token id_token":
                case "code id_token token":
                case "token code id_token":
                case "token id_token code":
                case "id_token token code":
                case "id_token code token":
                    allowedGrant = clientGrants.contains(HYBRID.getValue());
                    grantType = HYBRID;
                    break;
                default:
                    throw new InvalidGrantException("Specified grant is not recognized or supported");
            }
            if (!allowedGrant) {
                throw new InvalidGrantException("Specified grant is not recognized or supported");
            }
        }

        switch (grantType) {
            case AUTHORIZATION_CODE:
                validateAuthorizationCodeGrant(clientDetails, req);
                break;
            case IMPLICIT:
                validateImplicitGrant(clientDetails, req);
                break;
            case HYBRID:
                validateHybridGrant(clientDetails, req);
                break;
            default:
                throw new InvalidGrantException("Specified grant is not recognized or supported");
        }
    }

    private void validateHybridGrant(@NonNull ClientDetails clientDetails,
                                     @NonNull HttpServletRequest req)
    {
        validateAuthnRequest(clientDetails, req);
    }

    private void validateImplicitGrant(@NonNull ClientDetails clientDetails,
                                       @NonNull HttpServletRequest req)
    {
        validateAuthnRequest(clientDetails, req);
    }

    private void validateAuthorizationCodeGrant(@NonNull ClientDetails clientDetails,
                                                @NonNull HttpServletRequest req)
    {
        validateAuthnRequest(clientDetails, req);
    }

    private void validateAuthnRequest(@NonNull ClientDetails clientDetails,
                                      @NonNull HttpServletRequest req)
    {
        String scope = req.getParameter(SCOPE);
        Set<String> requestedScopes;
        if (StringUtils.hasText(scope)) {
            requestedScopes = new HashSet<>(Arrays.asList(scope.split(" ")));
        } else {
            requestedScopes = clientDetails.getScope();
        }
        if (!clientDetails.getScope().containsAll(requestedScopes)) {
            throw new InvalidScopeException("Client is not allowed to request some of the specified scopes");
        }
        String redirectUri = req.getParameter(REDIRECT_URI);
        if (!StringUtils.hasText(redirectUri)) {
            throw new InvalidRequestException("No redirect URI is provided.");
        }
        if (!clientDetails.getRegisteredRedirectUri().contains(redirectUri)) {
            throw new InvalidRequestException("Redirect URI does not match any of the registered ones");
        }
    }

}

