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