diff --git a/src/internal/oauth/client.zig b/src/internal/oauth/client.zig index 657cb0a..1b369b7 100644 --- a/src/internal/oauth/client.zig +++ b/src/internal/oauth/client.zig @@ -565,7 +565,16 @@ fn fetchWithDpopNonceRetry( }); const new_nonce = if (fetch_result.oauth.dpop_nonce) |value| try allocator.dupe(u8, value) else null; - if (new_nonce != null and isDpopNonceChallenge(fetch_result.status, fetch_result.body, fetch_result.oauth.www_authenticate)) { + const challenged = isDpopNonceChallenge( + fetch_result.status, + fetch_result.body, + fetch_result.oauth.www_authenticate, + ); + const selected_nonce = retainDpopNonce(challenged, new_nonce, nonce) catch |err| { + fetch_result.deinit(allocator); + return err; + }; + if (challenged) { fetch_result.deinit(allocator); if (retry_nonce) |value| allocator.free(value); retry_nonce = new_nonce.?; @@ -573,16 +582,11 @@ fn fetchWithDpopNonceRetry( continue; } - if (new_nonce == null and retry_nonce == null) { - fetch_result.deinit(allocator); - return error.MissingDpopNonce; - } - const body = fetch_result.body; fetch_result.body = &.{}; const returned_nonce = if (new_nonce) |value| value - else if (retry_nonce) |value| + else if (selected_nonce) |value| try allocator.dupe(u8, value) else null; @@ -596,6 +600,15 @@ fn fetchWithDpopNonceRetry( return error.DpopNonceRetryExhausted; } +fn retainDpopNonce( + challenged: bool, + response_nonce: ?[]const u8, + used_nonce: ?[]const u8, +) !?[]const u8 { + if (challenged) return response_nonce orelse error.MissingDpopNonce; + return response_nonce orelse used_nonce; +} + pub fn validateAuthorizationServerMetadata(metadata: AuthorizationServerMetadata) !void { try validateSimpleHttpsOrigin(metadata.issuer); if (!containsString(metadata.response_types_supported, "code")) return error.InvalidAuthorizationServerMetadata; @@ -873,6 +886,26 @@ test "DPoP nonce challenge detection" { try std.testing.expect(!isDpopNonceChallenge(.ok, "{\"error\":\"use_dpop_nonce\"}", null)); } +test "DPoP nonce is optional until challenged and retained until replaced" { + try std.testing.expect((try retainDpopNonce(false, null, null)) == null); + try std.testing.expectEqualStrings( + "prior", + (try retainDpopNonce(false, null, "prior")).?, + ); + try std.testing.expectEqualStrings( + "new", + (try retainDpopNonce(false, "new", "prior")).?, + ); + try std.testing.expectError( + error.MissingDpopNonce, + retainDpopNonce(true, null, null), + ); + try std.testing.expectEqualStrings( + "challenge", + (try retainDpopNonce(true, "challenge", null)).?, + ); +} + test "DPoP htu omits query and fragment" { const allocator = std.testing.allocator; const htu = try dpopHtu(allocator, "https://pds.example.com/xrpc/com.atproto.repo.getRecord?repo=did%3Aplc%3Aabc#frag");