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}