diff --git a/docs/reference/options.rst b/docs/reference/options.rst
index 9dfb8e2e..ff3c6417 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
diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/IdentityServerOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/IdentityServerOptions.cs
index 603281e4..0a1fced4 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 ca7ede7e..faf8b467 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 f28f2c3e..6e21435f 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 7fac1c75..a5da3d89 100644
--- a/src/Open.IdentityServer/src/Services/Default/DefaultBackChannelLogoutService.cs
+++ b/src/Open.IdentityServer/src/Services/Default/DefaultBackChannelLogoutService.cs
@@ -154,7 +154,7 @@ protected virtual async Task CreateTokenAsync(BackChannelLogoutRequest r
}
var issuer = request.Issuer ?? Tools.ContextAccessor.HttpContext.GetIdentityServerIssuerUri();
- return await Tools.IssueJwtAsync(DefaultLogoutTokenLifetime, issuer, claims);
+ 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 621a9e04..17d067eb 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;
}
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 5d0fa7f1..badeb5b7 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 e1cb7e2b..1fa83df8 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 2c82bab3..ee0ba22b 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