001/*
002 * SPDX-License-Identifier: Apache-2.0
003 *
004 * Copyright 2025-2026 The Enola <https://enola.dev> Authors
005 *
006 * Licensed under the Apache License, Version 2.0 (the "License");
007 * you may not use this file except in compliance with the License.
008 * You may obtain a copy of the License at
009 *
010 *     https://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing, software
013 * distributed under the License is distributed on an "AS IS" BASIS,
014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015 * See the License for the specific language governing permissions and
016 * limitations under the License.
017 */
018package dev.enola.audio.voice.twilio.security;
019
020import com.google.common.base.Strings;
021import com.twilio.security.RequestValidator;
022
023import dev.enola.common.context.TestContext;
024import dev.enola.common.secret.SecretManager;
025
026import org.jspecify.annotations.Nullable;
027import org.slf4j.Logger;
028import org.slf4j.LoggerFactory;
029
030import java.io.IOException;
031import java.util.Map;
032import java.util.Objects;
033
034public final class SignatureValidator {
035
036    private static final Logger logger = LoggerFactory.getLogger(SignatureValidator.class);
037
038    private final RequestValidator validator;
039
040    public SignatureValidator(SecretManager secretManager)
041            throws IllegalStateException, IOException {
042        String token =
043                TestContext.isUnderTest()
044                        ? "TESTING"
045                        : secretManager.get("TWILIO_AUTH_TOKEN").map(String::new);
046        this.validator = new RequestValidator(token);
047    }
048
049    public boolean validate(final String externalURL, @Nullable final String expectedSignature) {
050        if ("TRUE".equals(System.getenv("TWILIO_SKIP_AUTH"))) {
051            logger.warn("Skipping Twilio signature validation! :(");
052            return true;
053        }
054        if (TestContext.isUnderTest()) return true;
055        if (Strings.isNullOrEmpty(expectedSignature)) {
056            logger.warn("Missing Twilio signature");
057            return false;
058        }
059        if (Strings.isNullOrEmpty(externalURL)) {
060            logger.warn("Missing [null] external URL?!");
061            return false;
062        }
063
064        var isValid = validator.validate(externalURL, Map.of(), expectedSignature);
065        if (!isValid)
066            logger.warn("Invalid Twilio signature: {} for {}", expectedSignature, externalURL);
067        return isValid;
068    }
069
070    public boolean validate(
071            @Nullable final String hostHeader,
072            @Nullable final String forwardedHostsHeader,
073            @Nullable String path,
074            @Nullable final String expectedSignature) {
075        String hostname = null;
076        if (!Strings.isNullOrEmpty(forwardedHostsHeader)) {
077            hostname = forwardedHostsHeader;
078            // The X-Forwarded-Host header can be a comma-separated list of hosts;
079            // the first one is the original host.
080            int commaIndex = hostname.indexOf(',');
081            if (commaIndex != -1) {
082                hostname = hostname.substring(0, commaIndex);
083            }
084            hostname = hostname.trim();
085        } else {
086            hostname = hostHeader;
087        }
088        if (Strings.isNullOrEmpty(hostname)) {
089            return false;
090        }
091        if (Objects.isNull(path)) {
092            path = "";
093        }
094
095        var url = "wss://" + hostname + path;
096        return validate(url, expectedSignature);
097    }
098}