From 05f430e4b239b9275230d11da57537d957615f05 Mon Sep 17 00:00:00 2001 From: Oleks Dovzhenko Date: Tue, 27 Oct 2026 21:06:01 +0100 Subject: [PATCH 1/3] fix: Type back-channel logout tokens as logout+jwt --- .../Options/IdentityServerOptions.cs | 8 ++++++++ .../src/IdentityServerConstants.cs | 1 + .../src/IdentityServerTools.cs | 17 +++++++++++++++++ .../Default/DefaultBackChannelLogoutService.cs | 4 +++- .../Default/DefaultTokenCreationService.cs | 7 +++++++ 5 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/IdentityServerOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/IdentityServerOptions.cs index 603281e43..0a1fced45 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/IdentityServerOptions.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/IdentityServerOptions.cs @@ -34,6 +34,14 @@ public class IdentityServerOptions /// public string AccessTokenJwtType { get; set; } = "at+jwt"; + /// + /// Gets or sets the value for the JWT typ header for logout tokens. + /// + /// + /// The JWT typ value. + /// + public string LogoutTokenJwtType { get; set; } = "logout+jwt"; + /// /// Emits an aud claim with the format issuer/resources. That's needed for some older access token validation plumbing. Defaults to false. /// diff --git a/src/Open.IdentityServer/src/IdentityServerConstants.cs b/src/Open.IdentityServer/src/IdentityServerConstants.cs index ca7ede7ec..faf8b467b 100644 --- a/src/Open.IdentityServer/src/IdentityServerConstants.cs +++ b/src/Open.IdentityServer/src/IdentityServerConstants.cs @@ -61,6 +61,7 @@ public static class TokenTypes { public const string IdentityToken = "id_token"; public const string AccessToken = "access_token"; + public const string LogoutToken = "logout_token"; } public static class ClaimValueTypes diff --git a/src/Open.IdentityServer/src/IdentityServerTools.cs b/src/Open.IdentityServer/src/IdentityServerTools.cs index f28f2c3e7..6e21435ff 100644 --- a/src/Open.IdentityServer/src/IdentityServerTools.cs +++ b/src/Open.IdentityServer/src/IdentityServerTools.cs @@ -65,6 +65,22 @@ public virtual async Task IssueJwtAsync(int lifetime, IEnumerable /// /// claims public virtual async Task IssueJwtAsync(int lifetime, string issuer, IEnumerable claims) + { + return await IssueJwtAsync(lifetime, issuer, IdentityServerConstants.TokenTypes.AccessToken, claims); + } + + /// + /// Issues a JWT. + /// + /// The lifetime of the JWT in seconds. + /// The issuer to include in the JWT. + /// The token type to include in the JWT. + /// The claims to include in the JWT. + /// + /// A task that resolves to the signed JWT string. + /// + /// claims + public virtual async Task IssueJwtAsync(int lifetime, string issuer, string tokenType, IEnumerable claims) { if (String.IsNullOrWhiteSpace(issuer)) throw new ArgumentNullException(nameof(issuer)); if (claims == null) throw new ArgumentNullException(nameof(claims)); @@ -75,6 +91,7 @@ public virtual async Task IssueJwtAsync(int lifetime, string issuer, IEn { CreationTime = _clock.GetUtcNow().UtcDateTime, Issuer = issuer, + Type = tokenType, Lifetime = lifetime, Claims = new HashSet(claims, new ClaimComparer()), Audiences = audiences diff --git a/src/Open.IdentityServer/src/Services/Default/DefaultBackChannelLogoutService.cs b/src/Open.IdentityServer/src/Services/Default/DefaultBackChannelLogoutService.cs index 85495b448..2194bae1c 100644 --- a/src/Open.IdentityServer/src/Services/Default/DefaultBackChannelLogoutService.cs +++ b/src/Open.IdentityServer/src/Services/Default/DefaultBackChannelLogoutService.cs @@ -10,6 +10,7 @@ using System.Threading.Tasks; using Open.IdentityServer.Models; using Microsoft.Extensions.Logging; +using Open.IdentityServer.Extensions; using Open.IdentityServer.Utility; namespace Open.IdentityServer.Services; @@ -152,7 +153,8 @@ protected virtual async Task CreateTokenAsync(BackChannelLogoutRequest r throw new InvalidOperationException("nonce claim is not allowed in the back-channel signout token."); } - return await Tools.IssueJwtAsync(DefaultLogoutTokenLifetime, claims); + var issuer = Tools.ContextAccessor.HttpContext.GetIdentityServerIssuerUri(); + return await Tools.IssueJwtAsync(DefaultLogoutTokenLifetime, issuer, IdentityServerConstants.TokenTypes.LogoutToken, claims); } /// diff --git a/src/Open.IdentityServer/src/Services/Default/DefaultTokenCreationService.cs b/src/Open.IdentityServer/src/Services/Default/DefaultTokenCreationService.cs index 621a9e041..17d067eb2 100644 --- a/src/Open.IdentityServer/src/Services/Default/DefaultTokenCreationService.cs +++ b/src/Open.IdentityServer/src/Services/Default/DefaultTokenCreationService.cs @@ -121,6 +121,13 @@ protected virtual async Task CreateHeaderAsync(Token token) header["typ"] = Options.AccessTokenJwtType; } } + else if (token.Type == TokenTypes.LogoutToken) + { + if (Options.LogoutTokenJwtType.IsPresent()) + { + header["typ"] = Options.LogoutTokenJwtType; + } + } return header; } From e469b3e96c57cddb87751823675eca4b06c9314d Mon Sep 17 00:00:00 2001 From: Oleks Dovzhenko Date: Tue, 27 Oct 2026 21:06:05 +0100 Subject: [PATCH 2/3] test: Cover the logout token typ Co-Authored-By: Claude Opus 5.5 --- .../Endpoints/EndSession/EndSessionTests.cs | 28 ++++++++ .../IdentityServerToolsTests.cs | 26 +++++++ .../DefaultTokenCreationServiceTests.cs | 71 +++++++++++++++++++ 3 files changed, 125 insertions(+) diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/EndSession/EndSessionTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/EndSession/EndSessionTests.cs index 5d0fa7f18..badeb5b75 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/EndSession/EndSessionTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/EndSession/EndSessionTests.cs @@ -577,6 +577,34 @@ await _mockPipeline.BrowserClient.GetAsync( _mockPipeline.BackChannelMessageHandler.InvokeWasCalled.Should().BeTrue(); } + [Fact] + [Trait("Category", Category)] + public async Task back_channel_logout_token_should_have_logout_jwt_typ() + { + // assert outside OnInvoke: BackChannelLogoutHttpClient swallows exceptions thrown by the handler + string form = null; + _mockPipeline.BackChannelMessageHandler.OnInvoke = async req => form = await req.Content.ReadAsStringAsync(); + + await _mockPipeline.LoginAsync("bob"); + + await _mockPipeline.RequestAuthorizationEndpointAsync( + clientId: "client3", + responseType: "id_token", + scope: "openid", + redirectUri: "https://client3/callback", + state: "123_state", + nonce: "123_nonce"); + + await _mockPipeline.BrowserClient.GetAsync( + IdentityServerPipeline.EndSessionEndpoint, + TestContext.Current.CancellationToken); + + form.Should().StartWith(OidcConstants.BackChannelLogoutRequest.LogoutToken + "="); + var token = form.Split('=')[1]; + var header = JsonElement.Parse(Encoding.UTF8.GetString(Base64Url.Decode(token.Split('.')[0]))); + header.GetProperty("typ").GetString().Should().Be("logout+jwt"); + } + [Fact] [Trait("Category", Category)] public async Task back_channel_logout_should_not_affect_end_session_callback() diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/IdentityServerToolsTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/IdentityServerToolsTests.cs index e1cb7e2b9..1fa83df8d 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/IdentityServerToolsTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/IdentityServerToolsTests.cs @@ -66,4 +66,30 @@ public async Task IssueJwtAsync_WhenCalledWithMultipleClaimsContainingAudience_S t.Audiences.Contains(audience3))), Times.Once); } + + [Fact] + public async Task IssueJwtAsync_WhenCalledWithTokenType_ShouldSetTypeOnToken() + { + var sut = CreateSut(); + + await sut.IssueJwtAsync(300, "https://localhost:5000", IdentityServerConstants.TokenTypes.LogoutToken, + new List { new(JwtClaimTypes.Subject, "bob") }); + + _tokenCreationService.Verify(x => x.CreateTokenAsync(It.Is(t => + t.Type == IdentityServerConstants.TokenTypes.LogoutToken)), + Times.Once); + } + + [Fact] + public async Task IssueJwtAsync_WhenCalledWithoutTokenType_ShouldIssueAccessToken() + { + var sut = CreateSut(); + + await sut.IssueJwtAsync(3600, "https://localhost:5000", + new List { new(JwtClaimTypes.Subject, "bob") }); + + _tokenCreationService.Verify(x => x.CreateTokenAsync(It.Is(t => + t.Type == IdentityServerConstants.TokenTypes.AccessToken)), + Times.Once); + } } \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultTokenCreationServiceTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultTokenCreationServiceTests.cs index 2c82bab39..ee0ba22b8 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultTokenCreationServiceTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultTokenCreationServiceTests.cs @@ -3,8 +3,10 @@ using System; using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; using System.Security.Cryptography; using System.Threading.Tasks; +using AwesomeAssertions; using Microsoft.IdentityModel.Tokens; using Moq; using Open.IdentityServer.Configuration; @@ -48,6 +50,23 @@ private DefaultTokenCreationService CreateSubject() TestLogger.Create()); } + private Token CreateToken(string type) + { + var now = DateTime.UtcNow; + + _clock.Setup(c => c.GetUtcNow()).Returns(now); + + return new Token + { + Type = type, + CreationTime = now, + Lifetime = 60, + Issuer = "https://issuer.test" + }; + } + + private static string ReadTyp(string jwt) => new JwtSecurityTokenHandler().ReadJwtToken(jwt).Header.Typ; + [Fact] public async Task CreateTokenAsync_WhenCalled_ShouldInitiateTelemetryTrace() { @@ -77,4 +96,56 @@ public async Task CreateTokenAsync_WhenCalled_ShouldInitiateTelemetryTrace() "CreateTokenAsync")); _trace.Verify(t => t.Dispose(), Times.Once); } + + [Fact] + public async Task CreateTokenAsync_WhenTokenIsLogoutToken_ShouldSetTypToLogoutJwt() + { + var token = CreateToken(IdentityServerConstants.TokenTypes.LogoutToken); + + var subject = CreateSubject(); + + var jwt = await subject.CreateTokenAsync(token); + + ReadTyp(jwt).Should().Be("logout+jwt"); + } + + [Fact] + public async Task CreateTokenAsync_WhenLogoutTokenJwtTypeIsConfigured_ShouldUseConfiguredTyp() + { + _options.LogoutTokenJwtType = "at+jwt"; + var token = CreateToken(IdentityServerConstants.TokenTypes.LogoutToken); + + var subject = CreateSubject(); + + var jwt = await subject.CreateTokenAsync(token); + + ReadTyp(jwt).Should().Be("at+jwt"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task CreateTokenAsync_WhenLogoutTokenJwtTypeIsEmpty_ShouldFallBackToJwt(string logoutTokenJwtType) + { + _options.LogoutTokenJwtType = logoutTokenJwtType; + var token = CreateToken(IdentityServerConstants.TokenTypes.LogoutToken); + + var subject = CreateSubject(); + + var jwt = await subject.CreateTokenAsync(token); + + ReadTyp(jwt).Should().Be("JWT"); + } + + [Fact] + public async Task CreateTokenAsync_WhenTokenIsAccessToken_ShouldKeepAccessTokenTyp() + { + var token = CreateToken(OidcConstants.TokenTypes.AccessToken); + + var subject = CreateSubject(); + + var jwt = await subject.CreateTokenAsync(token); + + ReadTyp(jwt).Should().Be("at+jwt"); + } } \ No newline at end of file From 62d9e9c990e59f39ded948e3fbbeaffbe2fb9b1a Mon Sep 17 00:00:00 2001 From: Oleks Dovzhenko Date: Tue, 27 Oct 2026 21:06:09 +0100 Subject: [PATCH 3/3] docs: Document LogoutTokenJwtType Co-Authored-By: Claude Opus 5.5 --- docs/reference/options.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/reference/options.rst b/docs/reference/options.rst index 9dfb8e2ec..ff3c64171 100644 --- a/docs/reference/options.rst +++ b/docs/reference/options.rst @@ -12,6 +12,9 @@ IdentityServer Options * ``AccessTokenJwtType`` Specifies the value used for the JWT typ header for access tokens (defaults to ``at+jwt``). +* ``LogoutTokenJwtType`` + Specifies the value used for the JWT typ header for back-channel logout tokens (defaults to ``logout+jwt``). ``null`` or empty will use ``JWT``. + * ``EmitScopesAsSpaceDelimitedStringInJwt`` Specifies whether scopes in JWTs are emitted as array or string