From 5923533a4a62aee1e1b9cb6dcce90da816dba870 Mon Sep 17 00:00:00 2001 From: Kevin Dethlefs Date: Thu, 22 Oct 2026 23:24:01 -0500 Subject: [PATCH] EBS: do not report valid credentials as rejected by the startup check The startup check sends a deliberately invalid target and read any 401/403 as "Twitch refused the credentials". Twitch verifies the token before it checks what the token may do, so credentials that are right get "403 Permission denied to use target" for that target, while wrong ones get "403 JWT could not be verified". Observed against the live endpoint on a server whose real broadcasts were succeeding (204) while the check, and so /healthz and the app's warning, still said rejected. A 403 with that message now counts as accepted; every other 401/403 is still a rejection. Co-Authored-By: Claude Sonnet 5.5 --- src/EDNexus.Ebs/README.md | 5 +- .../Services/TwitchCredentialProbe.cs | 50 +++++++++++++------ .../TwitchCredentialCheckTests.cs | 30 +++++++++++ 3 files changed, 69 insertions(+), 16 deletions(-) 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() {