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()
{