/*
 * Copyright (c) 2018 Oracle and/or its affiliates. All rights reserved.
 *
 * 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 io.helidon.security.provider.httpauth;

import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Base64;
import java.util.Optional;
import java.util.Random;
import java.util.Set;

import io.helidon.common.Builder;
import io.helidon.common.CollectionsHelper;
import io.helidon.security.AuthenticationResponse;
import io.helidon.security.Principal;
import io.helidon.security.Security;
import io.helidon.security.SecurityContext;
import io.helidon.security.SecurityResponse;
import io.helidon.security.Subject;
import io.helidon.security.spi.AuthenticationProvider;

import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.CoreMatchers.not;
import static org.hamcrest.CoreMatchers.notNullValue;
import static org.hamcrest.CoreMatchers.startsWith;
import static org.hamcrest.MatcherAssert.assertThat;

/**
 * Unit test for {@link HttpBasicAuthProvider} and {@link HttpDigestAuthProvider}.
 */
public class HttpAuthProviderBuilderTest {
    private static final String QUOTE = "\"";
    private static Security security;
    private volatile static int counter = 0;
    private final Random random = new Random();
    private SecurityContext context;

    @BeforeAll
    public static void initClass() {
        UserStore us = userStore();

        security = Security.builder()
                .addAuthenticationProvider(basicAuthProvider(us), "basic")
                .addAuthenticationProvider(digestAuthProvider(false, us), "digest")
                .addAuthenticationProvider(digestAuthProvider(true, us), "digest_old")
                .build();
    }

    private static UserStore userStore() {
        return login -> {
            if (login.equals("jack")) {
                return Optional.of(new UserStore.User() {
                    @Override
                    public String getLogin() {
                        return "jack";
                    }

                    @Override
                    public char[] getPassword() {
                        return "jackIsGreat".toCharArray();
                    }

                    @Override
                    public Set<String> getRoles() {
                        return CollectionsHelper.setOf("user", "admin");
                    }
                });
            }
            if (login.equals("jill")) {
                return Optional.of(new UserStore.User() {
                    @Override
                    public String getLogin() {
                        return "jill";
                    }

                    @Override
                    public char[] getPassword() {
                        return "password".toCharArray();
                    }

                    @Override
                    public Set<String> getRoles() {
                        return CollectionsHelper.setOf("user");
                    }
                });
            }

            return Optional.empty();
        };
    }

    private static Builder<? extends AuthenticationProvider> basicAuthProvider(UserStore us) {
        return HttpBasicAuthProvider.builder()
                .realm("mic")
                .userStore(us);
    }

    private static Builder<? extends AuthenticationProvider> digestAuthProvider(boolean old, UserStore us) {
        HttpDigestAuthProvider.Builder builder = HttpDigestAuthProvider.builder()
                .realm("mic")
                .digestServerSecret("pwd".toCharArray())
                .userStore(us);

        if (old) {
            builder.noDigestQop();
        }

        return builder;
    }

    @BeforeEach
    public void init() {
        context = security.contextBuilder(String.valueOf(counter++))
                .build();
    }

    @Test
    public void basicTestFail() {
        AuthenticationResponse response = context.atnClientBuilder().buildAndGet();

        assertThat(response.getStatus().isSuccess(), is(false));
        assertThat(response.getStatusCode().orElse(200), is(401));
        String authHeader = response.getResponseHeaders().get(HttpBasicAuthProvider.HEADER_AUTHENTICATION_REQUIRED).get(0);
        assertThat(authHeader, notNullValue());
        assertThat(authHeader.toLowerCase(), is("basic realm=\"mic\""));
    }

    @Test
    public void basicTestJack() {
        setHeader(context, HttpBasicAuthProvider.HEADER_AUTHENTICATION, buildBasic("jack", "jackIsGreat"));

        AuthenticationResponse response = context.authenticate();

        assertThat(response.getStatus(), is(SecurityResponse.SecurityStatus.SUCCESS));
        assertThat(response.getStatusCode().orElse(200), is(200));

        assertThat(context.getUser().map(sub -> sub.getPrincipal().getName()).orElse(null), is("jack"));
        assertThat(context.isUserInRole("admin"), is(true));
        assertThat(context.isUserInRole("user"), is(true));
    }

    private void setHeader(SecurityContext context, String name, String value) {
        context.setEnv(context.getEnv()
                               .derive()
                               .header(name, value)
                               .build());
    }

    @Test
    public void basicTestInvalidUser() {
        setHeader(context, HttpBasicAuthProvider.HEADER_AUTHENTICATION, buildBasic("wrong", "user"));

        AuthenticationResponse response = context.authenticate();

        assertThat(response.getDescription().orElse(""), is("Invalid username or password"));
        assertThat(response.getStatus().isSuccess(), is(false));
        assertThat(response.getStatusCode().orElse(200), is(401));
        String authHeader = response.getResponseHeaders().get(HttpBasicAuthProvider.HEADER_AUTHENTICATION_REQUIRED).get(0);
        assertThat(authHeader, notNullValue());
        assertThat(authHeader.toLowerCase(), is("basic realm=\"mic\""));

        setHeader(context, HttpBasicAuthProvider.HEADER_AUTHENTICATION, buildBasic("jack", "invalid_passworrd"));

        response = context.authenticate();

        assertThat(response.getDescription().orElse(""), is("Invalid username or password"));
        assertThat(response.getStatus().isSuccess(), is(false));
        assertThat(response.getStatusCode().orElse(200), is(401));
    }

    @Test
    public void sendInvalidTypeTest() {
        setHeader(context, HttpBasicAuthProvider.HEADER_AUTHENTICATION, "bearer token=\"adfasfaf\"");

        AuthenticationResponse response = context.authenticate();

        assertThat(response.getStatus(), is(SecurityResponse.SecurityStatus.FAILURE));
        assertThat(response.getStatusCode().orElse(200), is(401));
    }

    @Test
    public void sendInvalidBasicTest() {
        setHeader(context, HttpBasicAuthProvider.HEADER_AUTHENTICATION, "basic wrong_header_value");
        AuthenticationResponse response = context.authenticate();

        assertThat(response.getStatus(), is(SecurityResponse.SecurityStatus.FAILURE));
        assertThat(response.getStatusCode().orElse(200), is(401));

        // not base64 encoded and invalid
        setHeader(context,
                  HttpBasicAuthProvider.HEADER_AUTHENTICATION,
                  "basic " + Base64.getEncoder().encodeToString("Hello".getBytes()));

        response = context.authenticate();

        assertThat(response.getStatus(), is(SecurityResponse.SecurityStatus.FAILURE));
        assertThat(response.getStatusCode().orElse(200), is(401));
    }

    @Test
    public void sendDigestNotBasicTest() {
        setHeader(context, HttpBasicAuthProvider.HEADER_AUTHENTICATION, buildDigest(HttpDigest.Qop.AUTH, "jack", "jackIsGreat"));
        AuthenticationResponse response = context.authenticate();

        assertThat(response.getStatus(), is(SecurityResponse.SecurityStatus.FAILURE));
        assertThat(response.getStatusCode().orElse(200), is(401));
    }

    @Test
    public void basicTestJill() {
        setHeader(context, HttpBasicAuthProvider.HEADER_AUTHENTICATION, buildBasic("jill", "password"));
        AuthenticationResponse response = context.authenticate();

        assertThat(response.getStatus(), is(SecurityResponse.SecurityStatus.SUCCESS));
        assertThat(response.getStatusCode().orElse(200), is(200));

        assertThat(getUsername(context), is("jill"));
        assertThat(context.isUserInRole("admin"), is(false));
        assertThat(context.isUserInRole("user"), is(true));
    }

    @Test
    public void digestTest401() {
        AuthenticationResponse response = context.atnClientBuilder()
                .explicitProvider("digest")
                .buildAndGet();

        assertThat(response.getStatus().isSuccess(), is(false));
        assertThat(response.getStatusCode().orElse(200), is(401));
        String authHeader = response.getResponseHeaders().get(HttpBasicAuthProvider.HEADER_AUTHENTICATION_REQUIRED).get(0);
        assertThat(authHeader, notNullValue());
        assertThat(authHeader.toLowerCase(), startsWith("digest realm=\"mic\""));
        assertThat(authHeader.toLowerCase(), containsString("qop="));
    }

    @Test
    public void digestOldTest401() {
        AuthenticationResponse response = context.atnClientBuilder()
                .explicitProvider("digest_old")
                .buildAndGet();

        assertThat(response.getStatus().isSuccess(), is(false));
        assertThat(response.getStatusCode().orElse(200), is(401));
        String authHeader = response.getResponseHeaders().get(HttpBasicAuthProvider.HEADER_AUTHENTICATION_REQUIRED).get(0);
        assertThat(authHeader, notNullValue());
        assertThat(authHeader.toLowerCase(), startsWith("digest realm=\"mic\""));
        assertThat(authHeader.toLowerCase(), not(containsString("qop=")));
    }

    @Test
    public void digestTestJack() {
        setHeader(context, HttpBasicAuthProvider.HEADER_AUTHENTICATION, buildDigest(HttpDigest.Qop.AUTH, "jack", "jackIsGreat"));
        AuthenticationResponse response = context.atnClientBuilder()
                .explicitProvider("digest")
                .buildAndGet();

        assertThat(response.getDescription().orElse("No description"),
                   response.getStatus(),
                   is(SecurityResponse.SecurityStatus.SUCCESS));
        assertThat(response.getStatusCode().orElse(200), is(200));

        assertThat(getUsername(context), is("jack"));
        assertThat(context.isUserInRole("admin"), is(true));
        assertThat(context.isUserInRole("user"), is(true));
    }

    @Test
    public void digestTestInvalidUser() {
        setHeader(context, HttpBasicAuthProvider.HEADER_AUTHENTICATION, buildDigest(HttpDigest.Qop.AUTH, "wrong", "user"));
        AuthenticationResponse response = context.atnClientBuilder()
                .explicitProvider("digest")
                .buildAndGet();

        assertThat(response.getDescription().orElse(""), is("Invalid username or password"));
        assertThat(response.getStatus().isSuccess(), is(false));
        assertThat(response.getStatusCode().orElse(200), is(401));
        String authHeader = response.getResponseHeaders().get(HttpBasicAuthProvider.HEADER_AUTHENTICATION_REQUIRED).get(0);
        assertThat(authHeader, notNullValue());
        assertThat(authHeader.toLowerCase(), startsWith("digest realm=\"mic\""));

        setHeader(context,
                  HttpBasicAuthProvider.HEADER_AUTHENTICATION,
                  buildDigest(HttpDigest.Qop.AUTH, "jack", "wrong password"));
        response = context.atnClientBuilder()
                .explicitProvider("digest")
                .buildAndGet();

        assertThat(response.getDescription().orElse(""), is("Invalid username or password"));
        assertThat(response.getStatus().isSuccess(), is(false));
        assertThat(response.getStatusCode().orElse(200), is(401));
    }

    @Test
    public void sendBasicNotDigestTest() {
        setHeader(context, HttpBasicAuthProvider.HEADER_AUTHENTICATION, buildBasic("jack", "jackIsGreat"));
        AuthenticationResponse response = context.atnClientBuilder()
                .explicitProvider("digest")
                .buildAndGet();

        assertThat(response.getStatus(), is(SecurityResponse.SecurityStatus.FAILURE));
        assertThat(response.getStatusCode().orElse(200), is(401));
    }

    @Test
    public void sendInvalidDigestTest() {
        setHeader(context, HttpBasicAuthProvider.HEADER_AUTHENTICATION, "digest wrong_header_value");
        AuthenticationResponse response = context.atnClientBuilder()
                .explicitProvider("digest")
                .buildAndGet();

        assertThat(response.getStatus(), is(SecurityResponse.SecurityStatus.FAILURE));
        assertThat(response.getStatusCode().orElse(200), is(401));
    }

    @Test
    public void digestTestJill() {
        setHeader(context, HttpBasicAuthProvider.HEADER_AUTHENTICATION, buildDigest(HttpDigest.Qop.AUTH, "jill", "password"));
        AuthenticationResponse response = context.atnClientBuilder()
                .explicitProvider("digest")
                .buildAndGet();

        assertThat(response.getDescription().orElse("No description"),
                   response.getStatus(),
                   is(SecurityResponse.SecurityStatus.SUCCESS));
        assertThat(response.getStatusCode().orElse(200), is(200));

        assertThat(getUsername(context), is("jill"));
        assertThat(context.isUserInRole("admin"), is(false));
        assertThat(context.isUserInRole("user"), is(true));
    }

    private String getUsername(SecurityContext context) {
        return context.getUser().map(Subject::getPrincipal).map(Principal::getName).orElse(null);
    }

    @Test
    public void digestOldTestJack() {
        setHeader(context, HttpBasicAuthProvider.HEADER_AUTHENTICATION, buildDigest(HttpDigest.Qop.NONE, "jack", "jackIsGreat"));
        AuthenticationResponse response = context.atnClientBuilder()
                .explicitProvider("digest_old")
                .buildAndGet();

        assertThat(response.getDescription().orElse("No description"),
                   response.getStatus(),
                   is(SecurityResponse.SecurityStatus.SUCCESS));
        assertThat(response.getStatusCode().orElse(200), is(200));

        assertThat(getUsername(context), is("jack"));
        assertThat(context.isUserInRole("admin"), is(true));
        assertThat(context.isUserInRole("user"), is(true));
    }

    @Test
    public void digestOldTestJill() {
        setHeader(context, HttpBasicAuthProvider.HEADER_AUTHENTICATION, buildDigest(HttpDigest.Qop.NONE, "jill", "password"));
        AuthenticationResponse response = context.atnClientBuilder()
                .explicitProvider("digest_old")
                .buildAndGet();

        assertThat(response.getDescription().orElse("No description"),
                   response.getStatus(),
                   is(SecurityResponse.SecurityStatus.SUCCESS));
        assertThat(response.getStatusCode().orElse(200), is(200));

        assertThat(getUsername(context), is("jill"));
        assertThat(context.isUserInRole("admin"), is(false));
        assertThat(context.isUserInRole("user"), is(true));
    }

    @Test
    public void digestTestNonceTimeout() {
        Instant in = Instant.now().minus(100, ChronoUnit.DAYS);

        setHeader(context, HttpBasicAuthProvider.HEADER_AUTHENTICATION,
                  buildDigest(HttpDigest.Qop.AUTH,
                              "jack",
                              "jackIsGreat",
                              HttpDigestAuthProvider.nonce(in.toEpochMilli(), random, "pwd".toCharArray()),
                              "mic"));

        AuthenticationResponse response = context.atnClientBuilder()
                .explicitProvider("digest")
                .buildAndGet();

        assertThat(response.getDescription().orElse(""), is("Nonce timeout"));
        assertThat(response.getStatus(), is(SecurityResponse.SecurityStatus.FAILURE));
        assertThat(response.getStatusCode().orElse(200), is(401));
    }

    @Test
    public void digestTestNonceNotB64() {
        setHeader(context, HttpBasicAuthProvider.HEADER_AUTHENTICATION,
                  buildDigest(HttpDigest.Qop.AUTH,
                              "jack",
                              "jackIsGreat",
                              "Not a base64 encoded $tring",
                              "mic"));
        AuthenticationResponse response = context.atnClientBuilder()
                .explicitProvider("digest")
                .buildAndGet();

        assertThat(response.getDescription().orElse(""), is("Nonce must be base64 encoded"));
        assertThat(response.getStatus(), is(SecurityResponse.SecurityStatus.FAILURE));
        assertThat(response.getStatusCode().orElse(200), is(401));
    }

    @Test
    public void digestTestNonceTooShort() {
        setHeader(context, HttpBasicAuthProvider.HEADER_AUTHENTICATION,
                  buildDigest(HttpDigest.Qop.AUTH,
                              "jack",
                              "jackIsGreat",
                              // must be base64 encoded string of less than 17 bytes
                              "wrongNonce",
                              "mic"));

        AuthenticationResponse response = context.atnClientBuilder()
                .explicitProvider("digest")
                .buildAndGet();
        assertThat(response.getDescription().orElse(""), is("Invalid nonce length"));
        assertThat(response.getStatus(), is(SecurityResponse.SecurityStatus.FAILURE));
        assertThat(response.getStatusCode().orElse(200), is(401));
    }

    @Test
    public void digestTestNonceNotEncrypted() {
        setHeader(context, HttpBasicAuthProvider.HEADER_AUTHENTICATION,
                  buildDigest(HttpDigest.Qop.AUTH,
                              "jack",
                              "jackIsGreat",
                              Base64.getEncoder()
                                      .encodeToString("4444444444444444444444444444444444444444444444".getBytes()),
                              "mic"));

        AuthenticationResponse response = context.atnClientBuilder()
                .explicitProvider("digest")
                .buildAndGet();
        assertThat(response.getDescription().orElse(""), is("Invalid nonce value"));
        assertThat(response.getStatus(), is(SecurityResponse.SecurityStatus.FAILURE));
        assertThat(response.getStatusCode().orElse(200), is(401));
    }

    @Test
    public void digestTestWrongRealm() {
        setHeader(context, HttpBasicAuthProvider.HEADER_AUTHENTICATION,
                  buildDigest(HttpDigest.Qop.AUTH,
                              "jack",
                              "jackIsGreat",
                              HttpDigestAuthProvider.nonce(System.currentTimeMillis(), random, "pwd".toCharArray()),
                              "wrongRealm"));
        AuthenticationResponse response = context.atnClientBuilder()
                .explicitProvider("digest")
                .buildAndGet();

        assertThat(response.getDescription().orElse(""), is("Invalid realm"));
        assertThat(response.getStatus(), is(SecurityResponse.SecurityStatus.FAILURE));
        assertThat(response.getStatusCode().orElse(200), is(401));
    }

    private String buildBasic(String user, String password) {
        return "basic " + Base64.getEncoder()
                .encodeToString((user + ":" + password).getBytes(StandardCharsets.UTF_8));
    }

    private String buildDigest(HttpDigest.Qop qop,
                               String user,
                               String password,
                               String nonce,
                               String realm) {

        StringBuilder result = new StringBuilder(100);

        String opaque = "someString";
        String path = "/digest";
        String cnonce = createCnonce();
        String nc = "00000001";

        DigestToken token = new DigestToken();
        token.setAlgorithm(HttpDigest.Algorithm.MD5);
        token.setQop(qop);
        token.setUsername(user);
        token.setOpaque(opaque);
        token.setRealm(realm);
        token.setMethod("GET");
        token.setUri(path);
        token.setNonce(nonce);
        token.setNc(nc);
        token.setCnonce(cnonce);

        String response = token.digest(password.toCharArray());

        result.append("digest ");
        result.append("username=").append(QUOTE).append(user).append(QUOTE);
        result.append(", realm=").append(QUOTE).append(realm).append(QUOTE);
        result.append(", nonce=").append(QUOTE).append(nonce).append(QUOTE);
        result.append(", uri=").append(QUOTE).append(path).append(QUOTE);
        result.append(", algorithm=MD5");
        result.append(", response=").append(QUOTE).append(response).append(QUOTE);
        result.append(", opaque=").append(QUOTE).append(opaque).append(QUOTE);
        if (qop != HttpDigest.Qop.NONE) {
            result.append(", qop=").append(qop.getQop());
        }
        result.append(", nc=").append(nc);
        result.append(", cnonce=").append(QUOTE).append(cnonce).append(QUOTE);

        return result.toString();
    }

    private String buildDigest(HttpDigest.Qop qop, String user, String password) {
        return buildDigest(qop,
                           user,
                           password,
                           HttpDigestAuthProvider.nonce(System.currentTimeMillis(), random, "pwd".toCharArray()),
                           "mic");
    }

    private String createCnonce() {
        byte[] cnonce = new byte[8];
        random.nextBytes(cnonce);

        return Base64.getEncoder().encodeToString(cnonce);
    }

}
