|
| 1 | +package ai.javaclaw.channels.teams; |
| 2 | + |
| 3 | +import com.nimbusds.jose.JWSAlgorithm; |
| 4 | +import com.nimbusds.jose.jwk.source.JWKSource; |
| 5 | +import com.nimbusds.jose.jwk.source.JWKSourceBuilder; |
| 6 | +import com.nimbusds.jose.proc.JWSVerificationKeySelector; |
| 7 | +import com.nimbusds.jose.proc.SecurityContext; |
| 8 | +import com.nimbusds.jwt.JWTClaimsSet; |
| 9 | +import com.nimbusds.jwt.SignedJWT; |
| 10 | +import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier; |
| 11 | +import com.nimbusds.jwt.proc.DefaultJWTProcessor; |
| 12 | +import org.slf4j.Logger; |
| 13 | +import org.slf4j.LoggerFactory; |
| 14 | + |
| 15 | +import java.net.MalformedURLException; |
| 16 | +import java.net.URI; |
| 17 | +import java.time.Duration; |
| 18 | +import java.util.Set; |
| 19 | + |
| 20 | +/** |
| 21 | + * Validates the Bot Framework Connector JWT sent on every incoming Teams webhook call |
| 22 | + * (the {@code Authorization: Bearer <token>} header), per |
| 23 | + * https://learn.microsoft.com/azure/bot-service/rest-api/bot-framework-rest-connector-authentication |
| 24 | + * <p> |
| 25 | + * A valid token must: (1) be signed with RS256 by a key currently published in Bot Framework's JWKS, |
| 26 | + * (2) have issuer {@code https://api.botframework.com}, (3) have this bot's App ID as audience, |
| 27 | + * (4) not be expired, allowing five minutes of clock skew as Microsoft recommends, and (5) carry a |
| 28 | + * {@code serviceUrl} claim equal to the activity's {@code serviceUrl}. The last check matters because |
| 29 | + * the bot later sends its own outbound token to that URL. The JWKS is fetched once and |
| 30 | + * cached/refreshed automatically by the underlying {@link JWKSource}. |
| 31 | + */ |
| 32 | +class BotFrameworkJwtValidator { |
| 33 | + |
| 34 | + private static final Logger LOGGER = LoggerFactory.getLogger(BotFrameworkJwtValidator.class); |
| 35 | + static final String EXPECTED_ISSUER = "https://api.botframework.com"; |
| 36 | + static final String SERVICE_URL_CLAIM = "serviceUrl"; |
| 37 | + static final int MAX_CLOCK_SKEW_SECONDS = (int) Duration.ofMinutes(5).toSeconds(); |
| 38 | + private static final String JWKS_URL = "https://login.botframework.com/v1/.well-known/keys"; |
| 39 | + |
| 40 | + private final DefaultJWTProcessor<SecurityContext> jwtProcessor; |
| 41 | + |
| 42 | + BotFrameworkJwtValidator(TeamsProperties properties) { |
| 43 | + this(properties, remoteJwkSource()); |
| 44 | + } |
| 45 | + |
| 46 | + /** Test seam: inject a local key set instead of hitting the real JWKS endpoint. */ |
| 47 | + BotFrameworkJwtValidator(TeamsProperties properties, JWKSource<SecurityContext> jwkSource) { |
| 48 | + String appId = properties.getAppId(); |
| 49 | + // An empty accepted-audience set rejects every token, which is what we want without an App ID |
| 50 | + Set<String> acceptedAudience = appId == null || appId.isBlank() ? Set.of() : Set.of(appId); |
| 51 | + |
| 52 | + DefaultJWTClaimsVerifier<SecurityContext> claimsVerifier = new DefaultJWTClaimsVerifier<>( |
| 53 | + acceptedAudience, |
| 54 | + new JWTClaimsSet.Builder().issuer(EXPECTED_ISSUER).build(), |
| 55 | + Set.of("exp", SERVICE_URL_CLAIM), |
| 56 | + null); |
| 57 | + claimsVerifier.setMaxClockSkew(MAX_CLOCK_SKEW_SECONDS); |
| 58 | + |
| 59 | + this.jwtProcessor = new DefaultJWTProcessor<>(); |
| 60 | + this.jwtProcessor.setJWSKeySelector(new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, jwkSource)); |
| 61 | + this.jwtProcessor.setJWTClaimsSetVerifier(claimsVerifier); |
| 62 | + } |
| 63 | + |
| 64 | + private static JWKSource<SecurityContext> remoteJwkSource() { |
| 65 | + try { |
| 66 | + return JWKSourceBuilder |
| 67 | + .create(URI.create(JWKS_URL).toURL()) |
| 68 | + .cache(Duration.ofMinutes(30).toMillis(), Duration.ofMinutes(1).toMillis()) |
| 69 | + .build(); |
| 70 | + } catch (MalformedURLException e) { |
| 71 | + throw new IllegalStateException("Invalid Bot Framework JWKS URL: " + JWKS_URL, e); |
| 72 | + } |
| 73 | + } |
| 74 | + |
| 75 | + /** |
| 76 | + * @param bearerToken the token from the {@code Authorization} header, without the {@code Bearer } prefix |
| 77 | + * @param serviceUrl the {@code serviceUrl} of the incoming activity |
| 78 | + * @return true if {@code bearerToken} is a currently valid Bot Framework Connector token for this bot |
| 79 | + * that was issued for {@code serviceUrl} |
| 80 | + */ |
| 81 | + boolean isValid(String bearerToken, String serviceUrl) { |
| 82 | + try { |
| 83 | + // Throws if the signature, algorithm, issuer, audience or expiry is wrong |
| 84 | + JWTClaimsSet claims = jwtProcessor.process(SignedJWT.parse(bearerToken), null); |
| 85 | + |
| 86 | + String tokenServiceUrl = claims.getStringClaim(SERVICE_URL_CLAIM); |
| 87 | + if (serviceUrl == null || !sameUrl(tokenServiceUrl, serviceUrl)) { |
| 88 | + LOGGER.warn("Rejected Teams webhook JWT issued for serviceUrl '{}' on an activity for '{}'", |
| 89 | + tokenServiceUrl, serviceUrl); |
| 90 | + return false; |
| 91 | + } |
| 92 | + return true; |
| 93 | + } catch (Exception e) { |
| 94 | + LOGGER.warn("Rejected Teams webhook JWT: {}", e.getMessage()); |
| 95 | + return false; |
| 96 | + } |
| 97 | + } |
| 98 | + |
| 99 | + /** Compares two URLs, ignoring a trailing slash and letter case. */ |
| 100 | + private static boolean sameUrl(String a, String b) { |
| 101 | + return a != null && stripTrailingSlash(a).equalsIgnoreCase(stripTrailingSlash(b)); |
| 102 | + } |
| 103 | + |
| 104 | + private static String stripTrailingSlash(String url) { |
| 105 | + return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; |
| 106 | + } |
| 107 | +} |
0 commit comments