/*
 * Copyright 2016 Red Hat, Inc. and/or its affiliates
 * and other contributors as indicated by the @author tags.
 *
 * 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
 *
 * http://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.keycloak.testsuite.client;


import java.util.ArrayList;
import java.util.Base64;
import java.util.Collections;
import java.util.List;

import jakarta.ws.rs.client.Client;
import jakarta.ws.rs.core.Response;

import org.keycloak.admin.client.resource.ClientResource;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.client.registration.Auth;
import org.keycloak.client.registration.ClientRegistrationException;
import org.keycloak.client.registration.HttpErrorException;
import org.keycloak.protocol.oidc.OIDCLoginProtocolFactory;
import org.keycloak.protocol.oidc.mappers.SHA256PairwiseSubMapper;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.IDToken;
import org.keycloak.representations.RefreshToken;
import org.keycloak.representations.UserInfo;
import org.keycloak.representations.idm.ClientInitialAccessCreatePresentation;
import org.keycloak.representations.idm.ClientInitialAccessPresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.representations.oidc.OIDCClientRepresentation;
import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.client.resources.TestApplicationResourceUrls;
import org.keycloak.testsuite.client.resources.TestOIDCEndpointsApplicationResource;
import org.keycloak.testsuite.util.AdminClientUtil;
import org.keycloak.testsuite.util.ClientManager;
import org.keycloak.testsuite.util.UserInfoClientUtil;
import org.keycloak.testsuite.util.UserManager;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse;
import org.keycloak.testsuite.util.oauth.OAuthClient;

import com.fasterxml.jackson.databind.JsonNode;
import org.apache.commons.lang.StringUtils;
import org.junit.Before;
import org.junit.Test;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;

public class OIDCPairwiseClientRegistrationTest extends AbstractClientRegistrationTest {

    @Before
    public void before() throws Exception {
        super.before();

        ClientInitialAccessPresentation token = adminClient.realm(REALM_NAME).clientInitialAccess().create(new ClientInitialAccessCreatePresentation(0, 10));
        reg.auth(Auth.token(token));
    }

    private OIDCClientRepresentation createRep() {
        OIDCClientRepresentation client = new OIDCClientRepresentation();
        client.setClientName("RegistrationAccessTokenTest");
        client.setClientUri(OAuthClient.APP_ROOT);
        client.setRedirectUris(Collections.singletonList(oauth.getRedirectUri()));
        return client;
    }

    public OIDCClientRepresentation create() throws ClientRegistrationException {
        OIDCClientRepresentation client = createRep();

        OIDCClientRepresentation response = reg.oidc().create(client);

        return response;
    }

    public OIDCClientRepresentation createPairwise() throws ClientRegistrationException {
        // Create pairwise client
        OIDCClientRepresentation clientRep = createRep();
        clientRep.setSubjectType("pairwise");
        OIDCClientRepresentation pairwiseClient = reg.oidc().create(clientRep);

        // No need to remove default sub mapper. As the pairwise sub mapper should be executed after the default one.
        //removeDefaultBasicClientScope(pairwiseClient.getClientId());

        return pairwiseClient;
    }


    private void assertCreateFail(OIDCClientRepresentation client, int expectedStatusCode, String expectedErrorContains) {
        try {
            reg.oidc().create(client);
            Assert.fail("Not expected to successfuly register client");
        } catch (ClientRegistrationException expected) {
            HttpErrorException httpEx = (HttpErrorException) expected.getCause();
            Assert.assertEquals(expectedStatusCode, httpEx.getStatusLine().getStatusCode());
            if (expectedErrorContains != null) {
                assertTrue("Error response doesn't contain expected text", httpEx.getErrorResponse().contains(expectedErrorContains));
            }
        }
    }

    @Test
    public void createPairwiseClient() throws Exception {
        OIDCClientRepresentation clientRep = createRep();
        clientRep.setSubjectType("pairwise");

        OIDCClientRepresentation response = reg.oidc().create(clientRep);
        Assert.assertEquals("pairwise", response.getSubjectType());
    }

    @Test
    public void updateClientToPairwise() throws Exception {
        OIDCClientRepresentation response = create();
        Assert.assertEquals("public", response.getSubjectType());

        reg.auth(Auth.token(response));
        response.setSubjectType("pairwise");
        OIDCClientRepresentation updated = reg.oidc().update(response);

        Assert.assertEquals("pairwise", updated.getSubjectType());
    }

    @Test
    public void updateSectorIdentifierUri() throws Exception {
        OIDCClientRepresentation clientRep = createRep();
        clientRep.setSubjectType("pairwise");
        OIDCClientRepresentation response = reg.oidc().create(clientRep);
        Assert.assertEquals("pairwise", response.getSubjectType());
        Assert.assertNull(response.getSectorIdentifierUri());

        reg.auth(Auth.token(response));

        // Push redirect uris to the sector identifier URI
        List<String> sectorRedirects = new ArrayList<>();
        sectorRedirects.addAll(response.getRedirectUris());
        TestOIDCEndpointsApplicationResource oidcClientEndpointsResource = testingClient.testApp().oidcClientEndpoints();
        oidcClientEndpointsResource.setSectorIdentifierRedirectUris(sectorRedirects);

        response.setSectorIdentifierUri(TestApplicationResourceUrls.pairwiseSectorIdentifierUri());

        OIDCClientRepresentation updated = reg.oidc().update(response);

        Assert.assertEquals("pairwise", updated.getSubjectType());
        Assert.assertEquals(TestApplicationResourceUrls.pairwiseSectorIdentifierUri(), updated.getSectorIdentifierUri());

    }

    @Test
    public void updateToPairwiseThroughAdminRESTSuccess() throws Exception {
        OIDCClientRepresentation response = create();
        Assert.assertEquals("public", response.getSubjectType());
        Assert.assertNull(response.getSectorIdentifierUri());

        // Push redirect uris to the sector identifier URI
        List<String> sectorRedirects = new ArrayList<>();
        sectorRedirects.addAll(response.getRedirectUris());
        TestOIDCEndpointsApplicationResource oidcClientEndpointsResource = testingClient.testApp().oidcClientEndpoints();
        oidcClientEndpointsResource.setSectorIdentifierRedirectUris(sectorRedirects);

        String sectorIdentifierUri = TestApplicationResourceUrls.pairwiseSectorIdentifierUri();

        // Add protocolMapper through admin REST endpoint
        String clientId = response.getClientId();
        ProtocolMapperRepresentation pairwiseProtMapper = SHA256PairwiseSubMapper.createPairwiseMapper(sectorIdentifierUri, null);
        RealmResource realmResource = realmsResouce().realm("test");
        ClientManager.realm(realmResource).clientId(clientId).addProtocolMapper(pairwiseProtMapper);

        reg.auth(Auth.token(response));
        OIDCClientRepresentation rep = reg.oidc().get(response.getClientId());
        Assert.assertEquals("pairwise", rep.getSubjectType());
        Assert.assertEquals(sectorIdentifierUri, rep.getSectorIdentifierUri());

    }

    @Test
    public void updateToPairwiseThroughAdminRESTFailure() throws Exception {
        OIDCClientRepresentation response = create();
        Assert.assertEquals("public", response.getSubjectType());
        Assert.assertNull(response.getSectorIdentifierUri());

        // Push empty list to the sector identifier URI
        TestOIDCEndpointsApplicationResource oidcClientEndpointsResource = testingClient.testApp().oidcClientEndpoints();
        oidcClientEndpointsResource.setSectorIdentifierRedirectUris(new ArrayList<>());

        String sectorIdentifierUri = TestApplicationResourceUrls.pairwiseSectorIdentifierUri();

        // Add protocolMapper through admin REST endpoint
        String clientId = response.getClientId();
        ProtocolMapperRepresentation pairwiseProtMapper = SHA256PairwiseSubMapper.createPairwiseMapper(sectorIdentifierUri, null);
        RealmResource realmResource = realmsResouce().realm("test");
        ClientResource clientResource = ApiUtil.findClientByClientId(realmsResouce().realm("test"), clientId);
        Response resp = clientResource.getProtocolMappers().createMapper(pairwiseProtMapper);
        Assert.assertEquals(400, resp.getStatus());

        // Assert still public
        reg.auth(Auth.token(response));
        OIDCClientRepresentation rep = reg.oidc().get(response.getClientId());
        Assert.assertEquals("public", rep.getSubjectType());
        Assert.assertNull(rep.getSectorIdentifierUri());
    }

    @Test
    public void createPairwiseClientWithSectorIdentifierURI() throws Exception {
        OIDCClientRepresentation clientRep = createRep();

        // Push redirect uris to the sector identifier URI
        List<String> sectorRedirects = new ArrayList<>();
        sectorRedirects.addAll(clientRep.getRedirectUris());
        TestOIDCEndpointsApplicationResource oidcClientEndpointsResource = testingClient.testApp().oidcClientEndpoints();
        oidcClientEndpointsResource.setSectorIdentifierRedirectUris(sectorRedirects);

        clientRep.setSubjectType("pairwise");
        clientRep.setSectorIdentifierUri(TestApplicationResourceUrls.pairwiseSectorIdentifierUri());

        OIDCClientRepresentation response = reg.oidc().create(clientRep);
        Assert.assertEquals("pairwise", response.getSubjectType());
        Assert.assertEquals(TestApplicationResourceUrls.pairwiseSectorIdentifierUri(), response.getSectorIdentifierUri());
    }

    @Test
    public void createPairwiseClientWithRedirectsToMultipleHostsWithoutSectorIdentifierURI() throws Exception {
        OIDCClientRepresentation clientRep = createRep();

        List<String> redirects = new ArrayList<>();
        redirects.add("http://redirect1");
        redirects.add("http://redirect2");

        clientRep.setSubjectType("pairwise");
        clientRep.setRedirectUris(redirects);

        assertCreateFail(clientRep, 400, "Without a configured Sector Identifier URI, client redirect URIs must not contain multiple host components.");
    }

    @Test
    public void createPairwiseClientWithRedirectsToMultipleHosts() throws Exception {
        OIDCClientRepresentation clientRep = createRep();

        // Push redirect URIs to the sector identifier URI
        List<String> redirects = new ArrayList<>();
        redirects.add("http://redirect1");
        redirects.add("http://redirect2");
        TestOIDCEndpointsApplicationResource oidcClientEndpointsResource = testingClient.testApp().oidcClientEndpoints();
        oidcClientEndpointsResource.setSectorIdentifierRedirectUris(redirects);

        clientRep.setSubjectType("pairwise");
        clientRep.setSectorIdentifierUri(TestApplicationResourceUrls.pairwiseSectorIdentifierUri());
        clientRep.setRedirectUris(redirects);

        OIDCClientRepresentation response = reg.oidc().create(clientRep);
        Assert.assertEquals("pairwise", response.getSubjectType());
        Assert.assertEquals(TestApplicationResourceUrls.pairwiseSectorIdentifierUri(), response.getSectorIdentifierUri());
        Assert.assertNames(response.getRedirectUris(), "http://redirect1", "http://redirect2");
    }

    @Test
    public void createPairwiseClientWithSectorIdentifierURIContainingMismatchedRedirects() throws Exception {
        OIDCClientRepresentation clientRep = createRep();

        // Push redirect uris to the sector identifier URI
        List<String> sectorRedirects = new ArrayList<>();
        sectorRedirects.add("http://someotherredirect");
        TestOIDCEndpointsApplicationResource oidcClientEndpointsResource = testingClient.testApp().oidcClientEndpoints();
        oidcClientEndpointsResource.setSectorIdentifierRedirectUris(sectorRedirects);

        clientRep.setSubjectType("pairwise");
        clientRep.setSectorIdentifierUri(TestApplicationResourceUrls.pairwiseSectorIdentifierUri());

        assertCreateFail(clientRep, 400, "Client redirect URIs does not match redirect URIs fetched from the Sector Identifier URI.");
    }

    @Test
    public void createPairwiseClientWithSectorIdentifierURIContainingMismatchedRedirectsPublicSubject() throws Exception {
        OIDCClientRepresentation clientRep = createRep();

        // Push redirect uris to the sector identifier URI
        List<String> sectorRedirects = new ArrayList<>();
        sectorRedirects.add("http://someotherredirect");
        TestOIDCEndpointsApplicationResource oidcClientEndpointsResource = testingClient.testApp().oidcClientEndpoints();
        oidcClientEndpointsResource.setSectorIdentifierRedirectUris(sectorRedirects);

        clientRep.setSubjectType("public");
        clientRep.setSectorIdentifierUri(TestApplicationResourceUrls.pairwiseSectorIdentifierUri());

        assertCreateFail(clientRep, 400, "Client redirect URIs does not match redirect URIs fetched from the Sector Identifier URI.");
    }

    @Test
    public void createPairwiseClientWithInvalidSectorIdentifierURI() throws Exception {
        OIDCClientRepresentation clientRep = createRep();
        clientRep.setSubjectType("pairwise");
        clientRep.setSectorIdentifierUri("malformed");
        assertCreateFail(clientRep, 400, "Invalid Sector Identifier URI.");
    }

    @Test
    public void createPairwiseClientWithUnreachableSectorIdentifierURI() throws Exception {
        OIDCClientRepresentation clientRep = createRep();
        clientRep.setSubjectType("pairwise");
        clientRep.setSectorIdentifierUri("http://localhost/dummy");
        assertCreateFail(clientRep, 400, "Failed to get redirect URIs from the Sector Identifier URI.");
    }

    @Test
    public void loginUserToPairwiseClient() throws Exception {
        // Create public client
        OIDCClientRepresentation publicClient = create();

        // Login to public client
        oauth.client(publicClient.getClientId(), publicClient.getClientSecret());
        AuthorizationEndpointResponse loginResponse = oauth.doLogin("test-user@localhost", "password");
        AccessTokenResponse accessTokenResponse = oauth.doAccessTokenRequest(loginResponse.getCode());
        AccessToken accessToken = oauth.verifyToken(accessTokenResponse.getAccessToken());
        Assert.assertEquals("test-user", accessToken.getPreferredUsername());
        Assert.assertEquals("test-user@localhost", accessToken.getEmail());
        String tokenUserId = accessToken.getSubject();

        // Assert public client has same subject like userId
        UserRepresentation user = realmsResouce().realm("test").users().search("test-user", 0, 1).get(0);
        Assert.assertEquals(user.getId(), tokenUserId);

        // Create pairwise client
        OIDCClientRepresentation pairwiseClient = createPairwise();
        Assert.assertEquals("pairwise", pairwiseClient.getSubjectType());
        // Login to pairwise client
        oauth.client(pairwiseClient.getClientId(), pairwiseClient.getClientSecret());
        oauth.openLoginForm();
        loginResponse = oauth.parseLoginResponse();
        accessTokenResponse = oauth.doAccessTokenRequest(loginResponse.getCode());

        // Assert token payloads don't contain more than one "sub"
        String accessTokenPayload = getPayload(accessTokenResponse.getAccessToken());
        Assert.assertEquals(1, StringUtils.countMatches(accessTokenPayload, "\"sub\""));
        String idTokenPayload = getPayload(accessTokenResponse.getIdToken());
        Assert.assertEquals(1, StringUtils.countMatches(idTokenPayload, "\"sub\""));
        String refreshTokenPayload = getPayload(accessTokenResponse.getRefreshToken());
        Assert.assertEquals(1, StringUtils.countMatches(refreshTokenPayload, "\"sub\""));

        accessToken = oauth.verifyToken(accessTokenResponse.getAccessToken());
        Assert.assertEquals("test-user", accessToken.getPreferredUsername());
        Assert.assertEquals("test-user@localhost", accessToken.getEmail());

        // Assert pairwise client has different subject than userId
        String pairwiseUserId = accessToken.getSubject();
        Assert.assertNotEquals(pairwiseUserId, user.getId());

        // Send request to userInfo endpoint
        Client jaxrsClient = AdminClientUtil.createResteasyClient();
        try {
            // Check that userInfo contains pairwise subjectId as well
            Response userInfoResponse = UserInfoClientUtil.executeUserInfoRequest_getMethod(jaxrsClient, accessTokenResponse.getAccessToken());
            UserInfo userInfo = UserInfoClientUtil.testSuccessfulUserInfoResponse(userInfoResponse, "test-user", "test-user@localhost");
            String userInfoSubId = userInfo.getSubject();
            Assert.assertEquals(pairwiseUserId, userInfoSubId);
        } finally {
            jaxrsClient.close();
        }
    }

    @Test
    public void refreshPairwiseToken() throws Exception {
        // Create pairwise client
        OIDCClientRepresentation pairwiseClient = createPairwise();
        // Login to pairwise client
        AccessTokenResponse accessTokenResponse = login(pairwiseClient, "test-user@localhost", "password");

        // Verify tokens
        oauth.parseRefreshToken(accessTokenResponse.getAccessToken());
        IDToken idToken = oauth.verifyIDToken(accessTokenResponse.getIdToken());
        oauth.parseRefreshToken(accessTokenResponse.getRefreshToken());

        // Refresh token
        AccessTokenResponse refreshTokenResponse = oauth.doRefreshTokenRequest(accessTokenResponse.getRefreshToken());

        // Verify refreshed tokens
        oauth.verifyToken(refreshTokenResponse.getAccessToken());
        RefreshToken refreshedRefreshToken = oauth.parseRefreshToken(refreshTokenResponse.getRefreshToken());
        IDToken refreshedIdToken = oauth.verifyIDToken(refreshTokenResponse.getIdToken());

        // If an ID Token is returned as a result of a token refresh request, the following requirements apply:
        // its iss Claim Value MUST be the same as in the ID Token issued when the original authentication occurred
        Assert.assertEquals(idToken.getIssuer(), refreshedRefreshToken.getIssuer());

        // its sub Claim Value MUST be the same as in the ID Token issued when the original authentication occurred
        Assert.assertEquals(idToken.getSubject(), refreshedRefreshToken.getSubject());

        // its iat Claim MUST represent the time that the new ID Token is issued
        Assert.assertEquals(refreshedIdToken.getIat(), refreshedRefreshToken.getIat());

        // if the ID Token contains an auth_time Claim, its value MUST represent the time of the original authentication
        // - not the time that the new ID token is issued
        Assert.assertEquals(idToken.getAuth_time(), refreshedIdToken.getAuth_time());

        // its azp Claim Value MUST be the same as in the ID Token issued when the original authentication occurred; if
        // no azp Claim was present in the original ID Token, one MUST NOT be present in the new ID Token
        Assert.assertEquals(idToken.getIssuedFor(), refreshedIdToken.getIssuedFor());
    }

    @Test
    public void introspectPairwiseAccessToken() throws Exception {
        // Create a pairwise client
        OIDCClientRepresentation pairwiseClient = createPairwise();

        // Login to pairwise client
        AccessTokenResponse accessTokenResponse = login(pairwiseClient, "test-user@localhost", "password");

        JsonNode jsonNode = oauth.client(pairwiseClient.getClientId(), pairwiseClient.getClientSecret()).doIntrospectionAccessTokenRequest(accessTokenResponse.getAccessToken()).asJsonNode();
        Assert.assertEquals(true, jsonNode.get("active").asBoolean());
        Assert.assertEquals("test-user@localhost", jsonNode.get("email").asText());
    }

    @Test
    public void refreshPairwiseTokenDeletedUser() throws Exception {
        String userId = createUser(REALM_NAME, "delete-me@localhost", "password");

        // Create pairwise client
        OIDCClientRepresentation pairwiseClient = createPairwise();

        // Login to pairwise client
        oauth.client(pairwiseClient.getClientId(), pairwiseClient.getClientSecret());
        AuthorizationEndpointResponse loginResponse = oauth.doLogin("delete-me@localhost", "password");
        AccessTokenResponse accessTokenResponse = oauth.doAccessTokenRequest(loginResponse.getCode());

        assertEquals(200, accessTokenResponse.getStatusCode());

        // Delete user
        adminClient.realm(REALM_NAME).users().delete(userId);

        AccessTokenResponse refreshTokenResponse = oauth.doRefreshTokenRequest(accessTokenResponse.getRefreshToken());
        assertEquals(400, refreshTokenResponse.getStatusCode());
        assertEquals("invalid_grant", refreshTokenResponse.getError());
        assertNull(refreshTokenResponse.getAccessToken());
        assertNull(refreshTokenResponse.getIdToken());
        assertNull(refreshTokenResponse.getRefreshToken());
    }

    @Test
    public void refreshPairwiseTokenDisabledUser() throws Exception {
        createUser(REALM_NAME, "disable-me@localhost", "password");

        // Create pairwise client
        OIDCClientRepresentation pairwiseClient = createPairwise();

        // Login to pairwise client
        oauth.client(pairwiseClient.getClientId(), pairwiseClient.getClientSecret());
        AuthorizationEndpointResponse loginResponse = oauth.doLogin("disable-me@localhost", "password");
        AccessTokenResponse accessTokenResponse = oauth.doAccessTokenRequest(loginResponse.getCode());
        assertEquals(200, accessTokenResponse.getStatusCode());

        try {
            UserManager.realm(adminClient.realm(REALM_NAME)).username("disable-me@localhost").enabled(false);

            AccessTokenResponse refreshTokenResponse = oauth.doRefreshTokenRequest(accessTokenResponse.getRefreshToken());
            assertEquals(400, refreshTokenResponse.getStatusCode());
            assertEquals("invalid_grant", refreshTokenResponse.getError());
            assertNull(refreshTokenResponse.getAccessToken());
            assertNull(refreshTokenResponse.getIdToken());
            assertNull(refreshTokenResponse.getRefreshToken());
        } finally {
            UserManager.realm(adminClient.realm(REALM_NAME)).username("disable-me@localhost").enabled(true);
        }
    }

    private AccessTokenResponse login(OIDCClientRepresentation client, String username, String password) {
        oauth.client(client.getClientId(), client.getClientSecret());
        AuthorizationEndpointResponse loginResponse = oauth.doLogin(username, password);
        return oauth.doAccessTokenRequest(loginResponse.getCode());
    }

    private String getPayload(String token) {
        String payloadBase64 = token.split("\\.")[1];
        return new String(Base64.getDecoder().decode(payloadBase64));
    }

    public void addDefaultBasicClientScope(String clientId) {
        realmsResouce().realm(REALM_NAME).getDefaultDefaultClientScopes()
                .stream()
                .filter(scope-> scope.getName().equals(OIDCLoginProtocolFactory.BASIC_SCOPE))
                .findFirst()
                .ifPresent(scope-> {
                    ApiUtil.findClientResourceByClientId(adminClient.realm(REALM_NAME), clientId).addDefaultClientScope(scope.getId());
                });
    }

    public void removeDefaultBasicClientScope(String clientId) {
        realmsResouce().realm(REALM_NAME).getDefaultDefaultClientScopes()
                .stream()
                .filter(scope-> scope.getName().equals(OIDCLoginProtocolFactory.BASIC_SCOPE))
                .findFirst()
                .ifPresent(scope-> {
                    ApiUtil.findClientResourceByClientId(adminClient.realm(REALM_NAME), clientId).removeDefaultClientScope(scope.getId());
                });
    }
}
