Skip to content

Commit f1bed77

Browse files
committed
system.text.json: bitbucket rest api custom converter
The Bitbucket authority returns the non-standard 'scopes' property with token endpoint results. With Json.NET, it was possible to handle this differentiation from the 'scope' property returned by other providers using inheritance. However, while this somewhat works in System.Text.Json, there is an issue with overwriting the value with the final property. So, for example, if 'scopes' is the final property in the JSON returned from Bitbucket's OAuth token endpoint, then the scopes property is correctly used in deserialization. However, if 'scope' is the final property, this value overwrites the scope value. For this reason, we use a custom converter to explicitly deserialize Bitbucket OAuth responses. For simplicity in working with System.Text.Json, we also store the 'expires_in' in a long rather than a TimeSpan.
1 parent 9dac85e commit f1bed77

File tree

7 files changed

+134
-59
lines changed

7 files changed

+134
-59
lines changed
Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,32 @@
1-
using Newtonsoft.Json;
1+
using System;
2+
using System.Text.Json;
23
using Xunit;
34

45
namespace Atlassian.Bitbucket.Tests
56
{
67
public class BitbucketTokenEndpointResponseJsonTest
78
{
89
[Fact]
9-
public void BitbucketTokenEndpointResponseJson_Deserialize_Scopes_Not_Scope()
10+
public void BitbucketTokenEndpointResponseJson_Deserialize_Uses_Scopes()
1011
{
11-
var scopesString = "a,b,c";
12-
var json = "{access_token: '', token_type: '', scopes:'" + scopesString + "', scope: 'x,y,z'}";
12+
var accessToken = "123";
13+
var tokenType = "Bearer";
14+
var expiresIn = 1000;
15+
var scopesString = "x,y,z";
16+
var scopeString = "a,b,c";
1317

14-
var result = JsonConvert.DeserializeObject<BitbucketTokenEndpointResponseJson>(json);
18+
var json = $"{{\"access_token\": \"{accessToken}\", \"token_type\": \"{tokenType}\", \"expires_in\": {expiresIn}, \"scopes\": \"{scopesString}\", \"scope\": \"{scopeString}\"}}";
1519

20+
var result = JsonSerializer.Deserialize<BitbucketTokenEndpointResponseJson>(json,
21+
new JsonSerializerOptions
22+
{
23+
PropertyNameCaseInsensitive = true
24+
});
25+
26+
Assert.Equal(accessToken, result.AccessToken);
27+
Assert.Equal(tokenType, result.TokenType);
28+
Assert.Equal(expiresIn, result.ExpiresIn);
1629
Assert.Equal(scopesString, result.Scope);
1730
}
1831
}
19-
}
32+
}
Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,73 @@
1+
using System;
2+
using System.Text.Json;
13
using GitCredentialManager.Authentication.OAuth.Json;
2-
using Newtonsoft.Json;
4+
using System.Text.Json.Serialization;
35

4-
namespace Atlassian.Bitbucket
6+
namespace Atlassian.Bitbucket
57
{
6-
public class BitbucketTokenEndpointResponseJson : TokenEndpointResponseJson
8+
[JsonConverter(typeof(BitbucketCustomTokenEndpointResponseJsonConverter))]
9+
public class BitbucketTokenEndpointResponseJson : TokenEndpointResponseJson
10+
{
11+
// To ensure the "scopes" property used by Bitbucket is deserialized successfully with System.Text.Json, we must
12+
// use a custom converter. Otherwise, ordering will matter (i.e. if "scopes" is the final property, its value
13+
// will be used, but if "scope" is the final property, its value will be used).
14+
}
15+
16+
public class BitbucketCustomTokenEndpointResponseJsonConverter : JsonConverter<BitbucketTokenEndpointResponseJson>
17+
{
18+
public override BitbucketTokenEndpointResponseJson Read(
19+
ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
720
{
8-
// Bitbucket uses "scopes" for the scopes property name rather than the standard "scope" name
9-
[JsonProperty("scopes")]
10-
public override string Scope { get; set; }
21+
if (reader.TokenType != JsonTokenType.StartObject)
22+
{
23+
throw new JsonException();
24+
}
25+
26+
var response = new BitbucketTokenEndpointResponseJson();
27+
28+
while (reader.Read())
29+
{
30+
if (reader.TokenType == JsonTokenType.EndObject)
31+
{
32+
return response;
33+
}
34+
35+
if (reader.TokenType == JsonTokenType.PropertyName)
36+
{
37+
var propertyName = reader.GetString();
38+
reader.Read();
39+
if (propertyName != null)
40+
{
41+
switch (propertyName)
42+
{
43+
case "access_token":
44+
response.AccessToken = reader.GetString();
45+
break;
46+
case "token_type":
47+
response.TokenType = reader.GetString();
48+
break;
49+
case "expires_in":
50+
if (reader.TryGetUInt32(out var expiration))
51+
response.ExpiresIn = expiration;
52+
else
53+
response.ExpiresIn = null;
54+
break;
55+
case "refresh_token":
56+
response.RefreshToken = reader.GetString();
57+
break;
58+
case "scopes":
59+
response.Scope = reader.GetString();
60+
break;
61+
}
62+
}
63+
}
64+
}
65+
66+
throw new JsonException();
1167
}
12-
}
68+
69+
public override void Write(
70+
Utf8JsonWriter writer, BitbucketTokenEndpointResponseJson tokenEndpointResponseJson, JsonSerializerOptions options)
71+
{ }
72+
}
73+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using System;
2+
using System.Text.Json;
3+
using GitCredentialManager.Authentication.OAuth.Json;
4+
using Xunit;
5+
6+
namespace Core.Tests;
7+
8+
public class TokenEndpointResponseJsonTest
9+
{
10+
[Fact]
11+
public void TokenEndpointResponseJson_Deserialize_Uses_Scope()
12+
{
13+
var accessToken = "123";
14+
var tokenType = "Bearer";
15+
var expiresIn = 1000;
16+
var scopesString = "x,y,z";
17+
var scopeString = "a,b,c";
18+
var json = $"{{\"access_token\": \"{accessToken}\", \"token_type\": \"{tokenType}\", \"expires_in\": {expiresIn}, \"scopes\": \"{scopesString}\", \"scope\": \"{scopeString}\"}}";
19+
20+
var result = JsonSerializer.Deserialize<TokenEndpointResponseJson>(json,
21+
new JsonSerializerOptions
22+
{
23+
PropertyNameCaseInsensitive = true
24+
});
25+
26+
Assert.Equal(accessToken, result.AccessToken);
27+
Assert.Equal(tokenType, result.TokenType);
28+
Assert.Equal(expiresIn, result.ExpiresIn);
29+
Assert.Equal(scopeString, result.Scope);
30+
}
31+
}

src/shared/Core/Authentication/OAuth/Json/DeviceAuthorizationEndpointResponseJson.cs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,16 @@ public class DeviceAuthorizationEndpointResponseJson
1515
public Uri VerificationUri { get; set; }
1616

1717
[JsonProperty("expires_in")]
18-
[JsonConverter(typeof(TimeSpanSecondsConverter))]
19-
public TimeSpan? ExpiresIn { get; set; }
18+
public long ExpiresIn { get; set; }
2019

2120
[JsonProperty("interval")]
22-
[JsonConverter(typeof(TimeSpanSecondsConverter))]
23-
public TimeSpan? PollingInterval { get; set; }
21+
public long PollingInterval { get; set; }
2422

2523
public OAuth2DeviceCodeResult ToResult()
2624
{
27-
return new OAuth2DeviceCodeResult(DeviceCode, UserCode, VerificationUri, PollingInterval)
25+
return new OAuth2DeviceCodeResult(DeviceCode, UserCode, VerificationUri, TimeSpan.FromSeconds(PollingInterval))
2826
{
29-
ExpiresIn = ExpiresIn
27+
ExpiresIn = TimeSpan.FromSeconds(ExpiresIn)
3028
};
3129
}
3230
}

src/shared/Core/Authentication/OAuth/Json/TimeSpanSecondsConverter.cs

Lines changed: 0 additions & 30 deletions
This file was deleted.

src/shared/Core/Authentication/OAuth/Json/TokenEndpointResponseJson.cs

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,33 @@
11
using System;
2-
using Newtonsoft.Json;
2+
using System.Text.Json;
3+
using System.Text.Json.Serialization;
34

45
namespace GitCredentialManager.Authentication.OAuth.Json
56
{
67
public class TokenEndpointResponseJson
78
{
8-
[JsonProperty("access_token", Required = Required.Always)]
9+
[JsonRequired]
10+
[JsonPropertyName("access_token")]
911
public string AccessToken { get; set; }
1012

11-
[JsonProperty("token_type", Required = Required.Always)]
13+
[JsonRequired]
14+
[JsonPropertyName("token_type")]
1215
public string TokenType { get; set; }
1316

14-
[JsonProperty("expires_in")]
15-
[JsonConverter(typeof(TimeSpanSecondsConverter))]
16-
public TimeSpan? ExpiresIn { get; set; }
17+
[JsonPropertyName("expires_in")]
18+
public long? ExpiresIn { get; set; }
1719

18-
[JsonProperty("refresh_token")]
20+
[JsonPropertyName("refresh_token")]
1921
public string RefreshToken { get; set; }
2022

21-
[JsonProperty("scope")]
23+
[JsonPropertyName("scope")]
2224
public virtual string Scope { get; set; }
2325

2426
public OAuth2TokenResult ToResult()
2527
{
2628
return new OAuth2TokenResult(AccessToken, TokenType)
2729
{
28-
ExpiresIn = ExpiresIn,
30+
ExpiresIn = ExpiresIn.HasValue ? TimeSpan.FromSeconds(ExpiresIn.Value) : null,
2931
RefreshToken = RefreshToken,
3032
Scopes = Scope?.Split(' ')
3133
};

src/shared/Core/Core.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
<Reference Include="System.Net.Http" />
1515
<Reference Include="System.Web" />
1616
<PackageReference Include="Microsoft.Identity.Client.Broker" Version="4.54.0" />
17-
<PackageReference Include="Avalonia.Win32" Version="11.0.0-preview6"/>
17+
<PackageReference Include="Avalonia.Win32" Version="11.0.0-preview6" />
1818
</ItemGroup>
1919

2020
<ItemGroup Condition="'$(TargetFramework)' != 'net472'">

0 commit comments

Comments
 (0)