diff --git a/src/EDNexus.Ebs/README.md b/src/EDNexus.Ebs/README.md index 51936cb..ddf44db 100644 --- a/src/EDNexus.Ebs/README.md +++ b/src/EDNexus.Ebs/README.md @@ -122,8 +122,9 @@ changed. Three things now make it loud: Helix PubSub endpoint a request signed exactly like a broadcast but aimed at a target Twitch cannot deliver to, so **nothing is ever sent to a viewer**. Twitch refusing the signed token (`401`/`403`) logs at **error**: `Twitch REJECTED the EBS's PubSub credentials (403: ...)`, naming - the three settings to check. A `400` (the credentials got through and only the bogus target was - refused) logs `Twitch did not refuse the EBS's PubSub credentials`. Anything else (a network error, + the three settings to check. A `400`, or a `403` saying `Permission denied to use target` (Twitch + verifies the token first, so this is what valid credentials get for the bogus target), logs + `Twitch did not refuse the EBS's PubSub credentials`. Anything else (a network error, a 5xx, a 429) is "inconclusive" and changes nothing. It never throws or stops the service. 2. **Real broadcasts keep the record.** Every actual PubSub call updates the same status: a refusal sets it, a success clears it. This is the authority, so a stale startup verdict cannot outlive the diff --git a/src/EDNexus.Ebs/Services/TwitchCredentialProbe.cs b/src/EDNexus.Ebs/Services/TwitchCredentialProbe.cs index 5e66742..70f4f37 100644 --- a/src/EDNexus.Ebs/Services/TwitchCredentialProbe.cs +++ b/src/EDNexus.Ebs/Services/TwitchCredentialProbe.cs @@ -31,17 +31,19 @@ public interface ITwitchCredentialProbe /// real broadcast but carries a deliberately invalid target, so it can never reach a viewer. /// /// -/// Twitch verifies the signed token before it looks at the body, so a refused credential comes back -/// 401/403 and an accepted one comes back 400 (the bogus target is what it then -/// rejects). Going through the same endpoint, Client-Id and token claims as a real publish -/// is the point: a different endpoint would be checking a different set of rules. +/// Twitch checks the request in this order: the body is validated first (a missing field is a +/// 400 whatever the token), then the token is verified (403 JWT could not be verified when +/// the secret, extension id or owner id is wrong), then the token's permissions are checked against the +/// target. So the bogus target here comes back 403 Permission denied to use target for valid +/// credentials and 403 JWT could not be verified for refused ones; both orderings were observed +/// against the live endpoint. Going through the same endpoint, Client-Id and token claims as a +/// real publish is the point: a different endpoint would be checking a different set of rules. /// -/// Two limits, stated rather than hidden. The refusal side is the one seen in production -/// (403 JWT could not be verified); that Twitch really does check the token before the body is -/// reasoned from its documentation, not observed, so an -/// here means "not refused", and only a real broadcast proves the credentials work. And this never -/// guesses: anything it does not recognise is , -/// which changes nothing. +/// Two limits, stated rather than hidden. An here +/// means "not refused", and only a real broadcast proves the credentials work. And this never guesses +/// a rejection: a 401 or a 403 it does not recognise as the permission answer is recorded +/// as a rejection, but a status that says nothing about the credentials is +/// , which changes nothing. /// /// public sealed class TwitchCredentialProbe : ITwitchCredentialProbe @@ -51,6 +53,9 @@ public sealed class TwitchCredentialProbe : ITwitchCredentialProbe /// Not a target Twitch knows (broadcast, global, whisper-…), so it is refused whatever else is true. internal const string InvalidTarget = "__ednexus_credential_check__"; + /// What Twitch says (as a 403) to a verified token that asks for a target it has no permission for. + internal const string TargetRefusedMessage = "Permission denied to use target"; + private readonly HttpClient _httpClient; private readonly ITwitchExtensionJwtService _jwtService; private readonly TwitchEbsOptions _options; @@ -115,6 +120,14 @@ private async Task ClassifyAsync(HttpResponseMessag if (TwitchCredentialStatus.IsCredentialRefusal(status)) { var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + + // Twitch verifies the token first and only then checks what it may do, so valid credentials + // meet this check's bogus target with a 403 "Permission denied to use target" (seen in + // production, where the same request with a wrong secret got "JWT could not be verified"). + // That is the expected answer, not a rejection. + if (status == 403 && body.Contains(TargetRefusedMessage, StringComparison.OrdinalIgnoreCase)) + return AcceptedWithInvalidTarget(); + _status.MarkRejected(status); _logger.LogError( "Twitch REJECTED the EBS's PubSub credentials ({StatusCode}: {Body}). Live updates will NOT reach viewers " + @@ -129,15 +142,24 @@ private async Task ClassifyAsync(HttpResponseMessag // have; the credentials are plainly not refused, but it is worth saying. if (status == 400 || response.IsSuccessStatusCode) { - _status.MarkAccepted(); if (response.IsSuccessStatusCode) + { + _status.MarkAccepted(); _logger.LogWarning("The Twitch credential check was accepted outright ({StatusCode}); it should have been refused for its invalid target.", status); - else - _logger.LogInformation("Twitch did not refuse the EBS's PubSub credentials (the check's invalid target was refused, as intended)."); - return TwitchCredentialProbeResult.Accepted; + return TwitchCredentialProbeResult.Accepted; + } + + return AcceptedWithInvalidTarget(); } _logger.LogWarning("The Twitch credential check got {StatusCode}, which says nothing about the credentials; leaving the status unchanged.", status); return TwitchCredentialProbeResult.Inconclusive; } + + private TwitchCredentialProbeResult AcceptedWithInvalidTarget() + { + _status.MarkAccepted(); + _logger.LogInformation("Twitch did not refuse the EBS's PubSub credentials (the check's invalid target was refused, as intended)."); + return TwitchCredentialProbeResult.Accepted; + } } diff --git a/tests/EDNexus.Ebs.Tests/TwitchCredentialCheckTests.cs b/tests/EDNexus.Ebs.Tests/TwitchCredentialCheckTests.cs index 559b48e..560810a 100644 --- a/tests/EDNexus.Ebs.Tests/TwitchCredentialCheckTests.cs +++ b/tests/EDNexus.Ebs.Tests/TwitchCredentialCheckTests.cs @@ -69,6 +69,36 @@ public async Task A_refused_token_is_a_rejection_and_is_recorded(HttpStatusCode Assert.Equal((int)code, status.Current.HttpStatus); } + [Fact] + public async Task A_403_permission_denied_for_the_target_means_the_token_was_verified_and_is_not_a_rejection() + { + // Exactly what Twitch answered the check once the credentials were right: it verifies the token + // first and only then finds the bogus target is not one the token may use. + var (probe, status, _) = Probe(HttpStatusCode.Forbidden, """{"error":"Forbidden","status":403,"message":"Error (403): Permission denied to use target"}"""); + + Assert.Equal(TwitchCredentialProbeResult.Accepted, await probe.CheckAsync(CancellationToken.None)); + Assert.Equal(TwitchCredentialState.Accepted, status.State); + Assert.Null(status.Current.HttpStatus); + } + + [Fact] + public async Task Any_other_403_is_still_a_rejection() + { + var (probe, status, _) = Probe(HttpStatusCode.Forbidden, """{"error":"Forbidden","status":403,"message":"Error (403): Wrong channel requested"}"""); + + Assert.Equal(TwitchCredentialProbeResult.Rejected, await probe.CheckAsync(CancellationToken.None)); + Assert.Equal(TwitchCredentialState.Rejected, status.State); + } + + [Fact] + public async Task A_401_is_a_rejection_even_if_its_body_mentions_the_permission_message() + { + var (probe, status, _) = Probe(HttpStatusCode.Unauthorized, """{"message":"Permission denied to use target"}"""); + + Assert.Equal(TwitchCredentialProbeResult.Rejected, await probe.CheckAsync(CancellationToken.None)); + Assert.Equal(TwitchCredentialState.Rejected, status.State); + } + [Fact] public async Task A_400_means_the_credentials_got_through_and_only_the_bogus_target_was_refused() {