Skip to content

Commit 40a517c

Browse files
Added more DPoP tests
1 parent 4c98b6a commit 40a517c

5 files changed

Lines changed: 316 additions & 1 deletion

File tree

src/OAuch/OAuch.Protocols/JWT/JoseHeader.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,10 @@ public bool IsValid {
4040
get {
4141
// at+JWT: https://tools.ietf.org/id/draft-bertocci-oauth-access-token-jwt-00.html
4242

43-
return (this.Type == null || string.Equals(this.Type, "JWT", StringComparison.OrdinalIgnoreCase) || string.Equals(this.Type, "at+JWT", StringComparison.OrdinalIgnoreCase)) // it must be a JWT
43+
return (this.Type == null
44+
|| string.Equals(this.Type, "JWT", StringComparison.OrdinalIgnoreCase)
45+
|| string.Equals(this.Type, "at+JWT", StringComparison.OrdinalIgnoreCase)
46+
|| string.Equals(this.Type, "dpop+JWT", StringComparison.OrdinalIgnoreCase)) // it must be a JWT
4447
&& (this.Algorithm?.Id ?? -1) > 0; // we must understand the algorithm used
4548
/*
4649
Verify that the resulting JOSE Header includes only parameters

src/OAuch/OAuch.Tests/ComplianceDatabase.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using OAuch.Compliance.Tests.AuthEndpoint;
2+
using OAuch.Compliance.Tests.DPoP;
23
using OAuch.Compliance.Tests.ParEndpoint;
34
using OAuch.Compliance.Threats;
45
using OAuch.Shared.Enumerations;
@@ -1664,6 +1665,21 @@ public static IReadOnlyList<OAuthDocument> AllDocuments {
16641665
RequirementLevel = RequirementLevels.Must,
16651666
LocationInDocument = "7. Protected Resource Access"
16661667
},
1668+
new TestRequirementLevel {
1669+
Test = Tests["OAuch.Compliance.Tests.DPoP.IsSignatureRequiredTest"],
1670+
RequirementLevel = RequirementLevels.Must,
1671+
LocationInDocument = "4.2. DPoP Proof JWT Syntax"
1672+
},
1673+
new TestRequirementLevel {
1674+
Test = Tests["OAuch.Compliance.Tests.DPoP.IsExplicitTypingRequiredTest"],
1675+
RequirementLevel = RequirementLevels.Must,
1676+
LocationInDocument = "4.2. DPoP Proof JWT Syntax"
1677+
},
1678+
new TestRequirementLevel {
1679+
Test = Tests["OAuch.Compliance.Tests.DPoP.HasRequiredClaimsTest"],
1680+
RequirementLevel = RequirementLevels.Must,
1681+
LocationInDocument = "4.2. DPoP Proof JWT Syntax"
1682+
},
16671683
]
16681684
},
16691685
new OAuthDocument { // This document has not been finished yet
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
using OAuch.Compliance.Tests.Features;
2+
using OAuch.Protocols.Http;
3+
using OAuch.Protocols.JWK;
4+
using OAuch.Protocols.JWT;
5+
using OAuch.Protocols.OAuth2;
6+
using OAuch.Protocols.OAuth2.BuildingBlocks;
7+
using OAuch.Protocols.OAuth2.Pipeline;
8+
using OAuch.Shared;
9+
using OAuch.Shared.Enumerations;
10+
using System;
11+
using System.Security.Claims;
12+
using System.Threading.Tasks;
13+
14+
namespace OAuch.Compliance.Tests.DPoP {
15+
public class HasRequiredClaimsTest : Test {
16+
public override string Title => "Are all required DPoP claims present";
17+
public override string Description => "This test checks if the authorization server only accepts DPoP proofs that contain all required claims ('jti', 'htm', 'htu', 'iat', and 'ath' for API requests).";
18+
public override TestResultFormatter ResultFormatter => TestResultFormatter.YesGoodNoBad;
19+
public override Type ResultType => typeof(HasRequiredClaimsTestResult);
20+
}
21+
22+
public class HasRequiredClaimsTestResult : TestResult {
23+
public HasRequiredClaimsTestResult(string testId) : base(testId) { }
24+
public override Type ImplementationType => typeof(HasRequiredClaimsTestImplementation);
25+
}
26+
27+
public class HasRequiredClaimsTestImplementation : TestImplementation {
28+
public HasRequiredClaimsTestImplementation(TestRunContext context, HasRequiredClaimsTestResult result, HasSupportedFlowsTestResult flows, IsDPoPSupportedTestResult dpop)
29+
: base(context, result, flows, dpop) { }
30+
31+
public override async Task Run() {
32+
if (HasFailed<IsDPoPSupportedTestResult>()) {
33+
Result.Outcome = TestOutcomes.Skipped;
34+
return;
35+
}
36+
37+
var flows = GetDependency<HasSupportedFlowsTestResult>(true);
38+
if (flows == null) {
39+
Result.Outcome = TestOutcomes.Skipped;
40+
return;
41+
}
42+
43+
var prov = flows.CreateProviderWithStage<AddDPoPHeader, HttpRequest, HttpRequest>(Context);
44+
if (prov == null) {
45+
Result.Outcome = TestOutcomes.Skipped;
46+
return;
47+
}
48+
var processor = new RemoveDPoPClaimProcessor();
49+
prov.Pipeline.Replace<AddDPoPHeader, HttpRequest, HttpRequest>(processor);
50+
51+
var requiredClaims = new[] { "jti", "htm", "htu", "iat" };
52+
foreach (var claim in requiredClaims) {
53+
processor.ClaimToRemove = claim;
54+
var token = await prov.GetToken();
55+
if (token.AccessToken != null || token.IdentityToken != null) {
56+
Result.Outcome = TestOutcomes.SpecificationNotImplemented;
57+
LogInfo($"The server accepted a DPoP proof without the required '{claim}' claim.");
58+
return;
59+
}
60+
}
61+
Result.Outcome = TestOutcomes.SpecificationFullyImplemented;
62+
63+
// Now test 'ath' claim (API request)
64+
var validProvider = flows.CreateProvider(Context, mustHaveDPoPTokens: true);
65+
if (validProvider == null) {
66+
return;
67+
}
68+
var validToken = await validProvider.GetToken();
69+
if (string.IsNullOrWhiteSpace(validToken.AccessToken)) {
70+
return;
71+
}
72+
73+
// Use a custom ApiRequest that removes 'ath' from the DPoP proof
74+
var apiRequest = new RemoveAthApiRequest(Context);
75+
var response = await apiRequest.Send(validToken);
76+
if (response.StatusCode.IsOk()) {
77+
Result.Outcome = TestOutcomes.SpecificationNotImplemented;
78+
LogInfo("The server accepted an API request with a DPoP proof missing the 'ath' claim.");
79+
return;
80+
}
81+
82+
LogInfo("The server correctly rejected DPoP proofs missing required claims.");
83+
}
84+
85+
// Processor to remove a specific claim from the DPoP proof
86+
public class RemoveDPoPClaimProcessor : Processor<HttpRequest, HttpRequest> {
87+
public string? ClaimToRemove { get; set; }
88+
public override Task<HttpRequest?> Process(HttpRequest value, IProvider provider, TokenResult tokenResult) {
89+
var dpop = OAuthHelper.CreateDPoPToken(provider.SiteSettings, value, null, tokenResult.AuthorizationDPoPNonce);
90+
if (dpop != null) {
91+
var builder = JwtTokenBuilder.CreateFromToken(dpop);
92+
if (builder != null && ClaimToRemove != null) {
93+
builder.Claims.Remove(ClaimToRemove);
94+
JsonWebKey key = JsonWebKey.Create(provider.SiteSettings.DPoPSigningKey)!;
95+
value.Headers[HttpRequestHeaders.DPoP] = builder.Build(key.Algorithm!, key.TokenKey);
96+
return Task.FromResult<HttpRequest?>(value);
97+
}
98+
}
99+
this.Succeeded = false;
100+
return Task.FromResult<HttpRequest?>(null);
101+
}
102+
}
103+
104+
// Custom ApiRequest that removes the 'ath' claim from the DPoP proof
105+
public class RemoveAthApiRequest : ApiRequest {
106+
public RemoveAthApiRequest(TestRunContext context) : base(context) { }
107+
protected override HttpRequest GetRequest(string uri, TokenResult token) {
108+
var req = base.GetRequest(uri, token);
109+
if (req.Headers.TryGetValue(HttpRequestHeaders.DPoP, out var dpop)) {
110+
var builder = JwtTokenBuilder.CreateFromToken(dpop, Context.Log);
111+
if (builder != null) {
112+
builder.Claims.Remove("ath");
113+
JsonWebKey key = JsonWebKey.Create(Context.SiteSettings.DPoPSigningKey)!;
114+
req.Headers[HttpRequestHeaders.DPoP] = builder.Build(key.Algorithm!, key.TokenKey);
115+
}
116+
}
117+
return req;
118+
}
119+
}
120+
}
121+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
using OAuch.Compliance.Tests.Features;
2+
using OAuch.Protocols.Http;
3+
using OAuch.Protocols.JWK;
4+
using OAuch.Protocols.JWT;
5+
using OAuch.Protocols.OAuth2;
6+
using OAuch.Protocols.OAuth2.BuildingBlocks;
7+
using OAuch.Protocols.OAuth2.Pipeline;
8+
using OAuch.Shared;
9+
using OAuch.Shared.Enumerations;
10+
using System;
11+
using System.Threading.Tasks;
12+
13+
namespace OAuch.Compliance.Tests.DPoP {
14+
public class IsExplicitTypingRequiredTest : Test {
15+
public override string Title => "Is explicit typing required in DPoP proofs";
16+
public override string Description => "This test checks if the authorization server requires the 'typ' header to be set to 'dpop+JWT' in the DPoP proof.";
17+
public override TestResultFormatter ResultFormatter => TestResultFormatter.YesGoodNoBad;
18+
public override Type ResultType => typeof(IsExplicitTypingRequiredTestResult);
19+
}
20+
21+
public class IsExplicitTypingRequiredTestResult : TestResult {
22+
public IsExplicitTypingRequiredTestResult(string testId) : base(testId) { }
23+
public override Type ImplementationType => typeof(IsExplicitTypingRequiredTestImplementation);
24+
}
25+
26+
public class IsExplicitTypingRequiredTestImplementation : TestImplementation {
27+
public IsExplicitTypingRequiredTestImplementation(TestRunContext context, IsExplicitTypingRequiredTestResult result, HasSupportedFlowsTestResult flows, IsDPoPSupportedTestResult dpop)
28+
: base(context, result, flows, dpop) { }
29+
30+
public override async Task Run() {
31+
if (HasFailed<IsDPoPSupportedTestResult>()) {
32+
Result.Outcome = TestOutcomes.Skipped;
33+
return;
34+
}
35+
36+
var flows = GetDependency<HasSupportedFlowsTestResult>(true);
37+
if (flows == null) {
38+
Result.Outcome = TestOutcomes.Skipped;
39+
return;
40+
}
41+
42+
var prov = flows.CreateProviderWithStage<AddDPoPHeader, HttpRequest, HttpRequest>(Context);
43+
if (prov == null) {
44+
Result.Outcome = TestOutcomes.Skipped;
45+
return;
46+
}
47+
48+
var processor = new TypingDPoPProcessor();
49+
processor.RemoveTyp = false; // first, set typ = JWT
50+
prov.Pipeline.Replace<AddDPoPHeader, HttpRequest, HttpRequest>(processor);
51+
52+
var token = await prov.GetToken();
53+
if (token.AccessToken == null && token.IdentityToken == null) {
54+
// now try without typ
55+
processor.RemoveTyp = true;
56+
var token2 = await prov.GetToken();
57+
if (token2.AccessToken == null && token2.IdentityToken == null) {
58+
Result.Outcome = TestOutcomes.SpecificationFullyImplemented;
59+
LogInfo("The server requires the 'typ' header to be set to 'dpop+JWT' in DPoP proofs.");
60+
} else {
61+
Result.Outcome = TestOutcomes.SpecificationNotImplemented;
62+
LogInfo("The server accepts DPoP proofs without 'typ' header.");
63+
}
64+
} else {
65+
Result.Outcome = TestOutcomes.SpecificationNotImplemented;
66+
LogInfo("The server did accepted a DPoP proof with 'typ' set to 'JWT'.");
67+
}
68+
}
69+
70+
public class TypingDPoPProcessor : Processor<HttpRequest, HttpRequest> {
71+
public bool RemoveTyp { get; set; } = false;
72+
public override Task<HttpRequest?> Process(HttpRequest value, IProvider provider, TokenResult tokenResult) {
73+
var dpop = OAuthHelper.CreateDPoPToken(provider.SiteSettings, value, null, tokenResult.AuthorizationDPoPNonce);
74+
if (dpop != null) {
75+
var builder = JwtTokenBuilder.CreateFromToken(dpop);
76+
if (builder != null) {
77+
if (RemoveTyp) {
78+
builder.Header.Remove("typ");
79+
} else {
80+
builder.Header["typ"] = "JWT";
81+
}
82+
JsonWebKey key = JsonWebKey.Create(provider.SiteSettings.DPoPSigningKey)!;
83+
value.Headers[HttpRequestHeaders.DPoP] = builder.Build(key.Algorithm!, key.TokenKey);
84+
return Task.FromResult<HttpRequest?>(value);
85+
}
86+
}
87+
this.Succeeded = false;
88+
return Task.FromResult<HttpRequest?>(null);
89+
}
90+
}
91+
}
92+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
using OAuch.Compliance.Tests.Features;
2+
using OAuch.Protocols.Http;
3+
using OAuch.Protocols.JWT;
4+
using OAuch.Protocols.OAuth2;
5+
using OAuch.Protocols.OAuth2.BuildingBlocks;
6+
using OAuch.Protocols.OAuth2.Pipeline;
7+
using OAuch.Shared;
8+
using OAuch.Shared.Enumerations;
9+
using System;
10+
using System.Collections.Generic;
11+
using System.Linq;
12+
using System.Text;
13+
using System.Threading.Tasks;
14+
15+
namespace OAuch.Compliance.Tests.DPoP {
16+
public class IsSignatureRequiredTest : Test {
17+
public override string Title => "Is a DPoP proof signature required";
18+
public override string Description => "This test attempts to obtain an access token while sending an unsigned (alg=none) DPoP proof and checks whether the authorization server rejects it.";
19+
public override TestResultFormatter ResultFormatter => TestResultFormatter.YesGoodNoBad;
20+
public override Type ResultType => typeof(IsSignatureRequiredTestResult);
21+
}
22+
23+
public class IsSignatureRequiredTestResult : TestResult {
24+
public IsSignatureRequiredTestResult(string testId) : base(testId) { }
25+
public override Type ImplementationType => typeof(IsSignatureRequiredTestImplementation);
26+
}
27+
28+
public class IsSignatureRequiredTestImplementation : TestImplementation {
29+
public IsSignatureRequiredTestImplementation(TestRunContext context, IsSignatureRequiredTestResult result, HasSupportedFlowsTestResult flows, IsDPoPSupportedTestResult dpop)
30+
: base(context, result, flows, dpop) { }
31+
32+
public async override Task Run() {
33+
if (HasFailed<IsDPoPSupportedTestResult>()) { // no DPoP support
34+
Result.Outcome = TestOutcomes.Skipped;
35+
return;
36+
}
37+
38+
var flows = GetDependency<HasSupportedFlowsTestResult>(true);
39+
if (flows == null) {
40+
Result.Outcome = TestOutcomes.Skipped;
41+
return;
42+
}
43+
44+
// Find a provider that uses the DPoP pipeline stage
45+
var prov = flows.CreateProviderWithStage<AddDPoPHeader, HttpRequest, HttpRequest>(Context);
46+
if (prov == null) {
47+
Result.Outcome = TestOutcomes.Skipped; // no provider that produces DPoP proofs
48+
return;
49+
}
50+
51+
// Replace the standard DPoP header generator with one that produces an unsigned proof (alg=none)
52+
prov.Pipeline.Replace<AddDPoPHeader, HttpRequest, HttpRequest>(new UnsignedDPoPProcessor());
53+
54+
var token = await prov.GetToken();
55+
if (token.AccessToken == null && token.IdentityToken == null) {
56+
// token issuance failed when unsigned DPoP was presented => server requires a signature
57+
Result.Outcome = TestOutcomes.SpecificationFullyImplemented;
58+
LogInfo("The server rejected the unsigned DPoP proof.");
59+
} else {
60+
Result.Outcome = TestOutcomes.SpecificationNotImplemented;
61+
LogInfo("The server issued a token despite an unsigned DPoP proof.");
62+
}
63+
}
64+
65+
class UnsignedDPoPProcessor : Processor<HttpRequest, HttpRequest> {
66+
public override Task<HttpRequest?> Process(HttpRequest value, IProvider provider, TokenResult tokenResult) {
67+
// Attempt to create a normal DPoP token first (to get payload structure),
68+
// then re-build it with alg=none / empty key so the signature is absent.
69+
var good = OAuthHelper.CreateDPoPToken(provider.SiteSettings, value, null, tokenResult.AuthorizationDPoPNonce);
70+
if (good != null) {
71+
var builder = JwtTokenBuilder.CreateFromToken(good);
72+
if (builder != null) {
73+
// Build unsigned token
74+
value.Headers[HttpRequestHeaders.DPoP] = builder.Build(JwtAlgorithm.None, TokenKey.Empty);
75+
return Task.FromResult<HttpRequest?>(value);
76+
}
77+
}
78+
this.Succeeded = false;
79+
return Task.FromResult<HttpRequest?>(null);
80+
}
81+
}
82+
}
83+
}

0 commit comments

Comments
 (0)