Skip to content

Commit 462b199

Browse files
authored
Fix for #1067 (#1071)
* Add suberror support (internal only) * Bump MSAL version of development envs to 3 * Fix for #1067 - FRT fail silently only on client_mismatch
1 parent a3c6cdd commit 462b199

File tree

8 files changed

+395
-295
lines changed

8 files changed

+395
-295
lines changed

LibsAndSamples.sln

Lines changed: 287 additions & 278 deletions
Large diffs are not rendered by default.

src/Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222

2323
<PropertyGroup Label="NuGet and AssemblyInfo metadata">
2424
<!--This should be passed from the VSTS build-->
25-
<MsalClientSemVer Condition="'$(MsalClientSemVer)' == ''">1.0.0-localbuild</MsalClientSemVer>
25+
<MsalClientSemVer Condition="'$(MsalClientSemVer)' == ''">3.0.4-localbuild</MsalClientSemVer>
2626
<!--This will generate AssemblyVersion, AssemblyFileVersion and AssemblyInformationVersion-->
2727
<Version>$(MsalClientSemVer)</Version>
2828

src/Microsoft.Identity.Client/Internal/Requests/SilentRequest.cs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ internal class SilentRequest : RequestBase
4242
{
4343
private readonly AcquireTokenSilentParameters _silentParameters;
4444
private const string TheOnlyFamilyId = "1";
45+
private const string FociClientMismatchSubError = "client_mismatch";
4546

4647
public SilentRequest(
4748
IServiceBundle serviceBundle,
@@ -173,13 +174,29 @@ private async Task<MsalTokenResponse> TryGetTokenUsingFociAsync(CancellationToke
173174
MsalTokenResponse frtTokenResponse = await RefreshAccessTokenAsync(familyRefreshToken, cancellationToken)
174175
.ConfigureAwait(false);
175176

176-
logger.Verbose("[FOCI] FRT exchanged succeeded");
177+
logger.Verbose("[FOCI] FRT refresh succeeded");
177178
return frtTokenResponse;
178179
}
179-
catch (MsalServiceException)
180+
catch (MsalServiceException ex)
180181
{
181-
logger.Error("[FOCI] FRT exchanged failed " + (familyRefreshToken != null));
182+
183+
// Hack: STS does not yet send back the suberror on these platforms because they are not in an allowed list,
184+
// so the best thing we can do is to consider all errors as client_mismatch.
185+
#if NETSTANDARD || UAP || MAC
182186
return null;
187+
#endif
188+
if (MsalError.InvalidGrantError.Equals(ex?.ErrorCode, StringComparison.OrdinalIgnoreCase) &&
189+
FociClientMismatchSubError.Equals(ex?.SubError, StringComparison.OrdinalIgnoreCase))
190+
{
191+
logger.Error("[FOCI] FRT refresh failed - client mismatch");
192+
return null;
193+
}
194+
195+
// Rethrow failures to refresh the FRT, other than client_mismatch, because
196+
// apps need to handle them in the same way they handle exceptions from refreshing the RT.
197+
// For example, some apps have special handling for MFA errors.
198+
logger.Error("[FOCI] FRT refresh failed - other error");
199+
throw;
183200
}
184201
}
185202

src/Microsoft.Identity.Client/MsalServiceException.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,12 +174,13 @@ internal OAuth2ResponseBase OAuth2Response
174174
_oauth2ResponseBase = value;
175175
Claims = _oauth2ResponseBase?.Claims;
176176
CorrelationId = _oauth2ResponseBase?.CorrelationId;
177+
SubError = _oauth2ResponseBase?.SubError;
177178
}
178179
}
179180

180181
/// <summary>
181182
/// Gets the status code returned from http layer. This status code is either the <c>HttpStatusCode</c> in the inner
182-
/// <see cref="T:System.Net.Http.HttpRequestException"/> response or the the NavigateError Event Status Code in a browser based flow (See
183+
/// <see cref="System.Net.Http.HttpRequestException"/> response or the the NavigateError Event Status Code in a browser based flow (See
183184
/// http://msdn.microsoft.com/en-us/library/bb268233(v=vs.85).aspx).
184185
/// You can use this code for purposes such as implementing retry logic or error investigation.
185186
/// </summary>
@@ -209,6 +210,12 @@ internal OAuth2ResponseBase OAuth2Response
209210
/// </summary>
210211
public string ResponseBody { get; internal set; }
211212

213+
/// <remarks>
214+
/// The suberror should not be exposed for public consumption yet, as STS needs to do some work
215+
/// first.
216+
/// </remarks>
217+
internal string SubError { get; set; }
218+
212219
/// <summary>
213220
/// Contains the http headers from the server response that indicated an error.
214221
/// </summary>
@@ -240,6 +247,7 @@ public override string ToString()
240247
private const string ClaimsKey = "claims";
241248
private const string ResponseBodyKey = "response_body";
242249
private const string CorrelationIdKey = "correlation_id";
250+
private const string SubErrorKey = "sub_error";
243251

244252
internal override void PopulateJson(JObject jobj)
245253
{
@@ -248,6 +256,7 @@ internal override void PopulateJson(JObject jobj)
248256
jobj[ClaimsKey] = Claims;
249257
jobj[ResponseBodyKey] = ResponseBody;
250258
jobj[CorrelationIdKey] = CorrelationId;
259+
jobj[SubErrorKey] = SubError;
251260
}
252261

253262
internal override void PopulateObjectFromJson(JObject jobj)
@@ -257,6 +266,7 @@ internal override void PopulateObjectFromJson(JObject jobj)
257266
Claims = JsonUtils.GetExistingOrEmptyString(jobj, ClaimsKey);
258267
ResponseBody = JsonUtils.GetExistingOrEmptyString(jobj, ResponseBodyKey);
259268
CorrelationId = JsonUtils.GetExistingOrEmptyString(jobj, CorrelationIdKey);
269+
SubError = JsonUtils.GetExistingOrEmptyString(jobj, SubErrorKey);
260270
}
261271
}
262272
}

tests/Microsoft.Identity.Test.Common/Core/Mocks/MockHelpers.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,15 +140,17 @@ public static HttpResponseMessage CreateSuccessTokenResponseMessage(bool foci =
140140
foci ? FociTokenResponse : DefaultTokenResponse);
141141
}
142142

143-
public static HttpResponseMessage CreateInvalidGrantTokenResponseMessage()
143+
public static HttpResponseMessage CreateInvalidGrantTokenResponseMessage(string subError = null)
144144
{
145145
return CreateFailureMessage(HttpStatusCode.BadRequest,
146146
"{\"error\":\"invalid_grant\",\"error_description\":\"AADSTS70002: Error " +
147147
"validating credentials.AADSTS70008: The provided access grant is expired " +
148148
"or revoked.Trace ID: f7ec686c-9196-4220-a754-cd9197de44e9Correlation ID: " +
149149
"04bb0cae-580b-49ac-9a10-b6c3316b1eaaTimestamp: 2015-09-16 07:24:55Z\"," +
150150
"\"error_codes\":[70002,70008],\"timestamp\":\"2015-09-16 07:24:55Z\"," +
151-
"\"trace_id\":\"f7ec686c-9196-4220-a754-cd9197de44e9\",\"correlation_id\":" +
151+
"\"trace_id\":\"f7ec686c-9196-4220-a754-cd9197de44e9\"," +
152+
(subError != null ? ("\"suberror\":" + "\"" + subError + "\",") : "") +
153+
"\"correlation_id\":" +
152154
"\"04bb0cae-580b-49ac-9a10-b6c3316b1eaa\"}");
153155
}
154156

tests/Microsoft.Identity.Test.Unit.net45/ExceptionTests/MsalExceptionFactoryTests.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ public class MsalExceptionFactoryTests
4545
private const string ExCode = "exCode";
4646
private const string ExMessage = "exMessage";
4747

48-
private const string jsonError = @"{ ""error"":""invalid_tenant"", ""suberror"":""suberror"",
48+
private const string jsonError = @"{ ""error"":""invalid_tenant"", ""suberror"":""some_suberror"",
4949
""claims"":""some_claims"",
5050
""error_description"":""AADSTS90002: Tenant 'x' not found. "", ""error_codes"":[90002],""timestamp"":""2019-01-28 14:16:04Z"",
5151
""trace_id"":""43f14373-8d7d-466e-a5f1-6e3889291e00"",
@@ -70,7 +70,7 @@ public void ParamValidation()
7070
}
7171

7272
[TestMethod]
73-
public void MsalClientException_FromCoreException()
73+
public void MsalClientException_FromMessageAndCode()
7474
{
7575
// Act
7676
var msalException = new MsalClientException(ExCode, ExMessage);
@@ -118,6 +118,7 @@ public void MsalServiceException_Oauth2Response_Only()
118118
Assert.AreEqual(ExMessage, msalServiceException.Message);
119119
Assert.AreEqual("some_claims", msalServiceException.Claims);
120120
Assert.AreEqual("6347d33d-941a-4c35-9912-a9cf54fb1b3e", msalServiceException.CorrelationId);
121+
Assert.AreEqual("some_suberror", msalServiceException.SubError);
121122
}
122123

123124
[TestMethod]
@@ -150,6 +151,7 @@ public void MsalServiceException_HttpResponse_OAuthResponse()
150151

151152
Assert.AreEqual("some_claims", msalServiceException.Claims);
152153
Assert.AreEqual("6347d33d-941a-4c35-9912-a9cf54fb1b3e", msalServiceException.CorrelationId);
154+
Assert.AreEqual("some_suberror", msalServiceException.SubError);
153155

154156
// Act
155157
string piiMessage = MsalLogger.GetPiiScrubbedExceptionDetails(msalException);
@@ -236,6 +238,7 @@ public void MsalServiceException_FromHttpResponse()
236238
Assert.AreEqual(responseBody, msalServiceException.ResponseBody);
237239
Assert.AreEqual(ExMessage, msalServiceException.Message);
238240
Assert.AreEqual((int)statusCode, msalServiceException.StatusCode);
241+
Assert.AreEqual("some_suberror", msalServiceException.SubError);
239242

240243
Assert.AreEqual(retryAfterSpan, msalServiceException.Headers.RetryAfter.Delta);
241244
}

tests/Microsoft.Identity.Test.Unit.net45/ExceptionTests/MsalExceptionSerializationTests.cs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ public class MsalExceptionSerializationTests
4040
private const string SomeClaims = "here are some claims";
4141
private const string SomeCorrelationId = "the correlation id";
4242
private const string SomeResponseBody = "the response body";
43+
private const string SomeSubError = "the_sub_error";
44+
4345

4446
private void SerializeDeserializeAndValidate(MsalException ex, Type expectedType, bool isServiceExceptionDerived)
4547
{
@@ -58,6 +60,7 @@ private void SerializeDeserializeAndValidate(MsalException ex, Type expectedType
5860
Assert.AreEqual(SomeClaims, svcEx.Claims);
5961
Assert.AreEqual(SomeResponseBody, svcEx.ResponseBody);
6062
Assert.AreEqual(SomeCorrelationId, svcEx.CorrelationId);
63+
Assert.AreEqual(SomeSubError, svcEx.SubError);
6164
}
6265
}
6366

@@ -75,7 +78,8 @@ public void MsalServiceExceptionCanSerializeAndDeserializeRoundTrip()
7578
{
7679
Claims = SomeClaims,
7780
CorrelationId = SomeCorrelationId,
78-
ResponseBody = SomeResponseBody
81+
ResponseBody = SomeResponseBody,
82+
SubError = SomeSubError
7983
};
8084

8185
SerializeDeserializeAndValidate(ex, typeof(MsalServiceException), true);
@@ -95,7 +99,8 @@ public void MsalUiRequiredExceptionCanSerializeAndDeserializeRoundTrip()
9599
{
96100
Claims = SomeClaims,
97101
CorrelationId = SomeCorrelationId,
98-
ResponseBody = SomeResponseBody
102+
ResponseBody = SomeResponseBody,
103+
SubError = SomeSubError
99104
};
100105

101106
SerializeDeserializeAndValidate(ex, typeof(MsalUiRequiredException), true);

tests/Microsoft.Identity.Test.Unit.net45/RequestsTests/FociTests.cs

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ private enum ServerTokenResponse
4949
{
5050
NonFociToken,
5151
FociToken,
52-
Error
52+
ErrorClientMismatch,
53+
OtherError
5354
}
5455

5556
private string _inMemoryCache;
@@ -105,7 +106,8 @@ public async Task FociAndNonFociAppsCoexistAsync()
105106
await InteractiveAsync(_appA, ServerTokenResponse.FociToken).ConfigureAwait(false);
106107

107108
// B cannot acquire a token interactivelty, but will try to use FRT
108-
AssertException.TaskThrows<MsalUiRequiredException>(() => SilentAsync(_appB, ServerTokenResponse.Error));
109+
var ex = AssertException.TaskThrows<MsalUiRequiredException>(() => SilentAsync(_appB, ServerTokenResponse.ErrorClientMismatch));
110+
Assert.AreEqual(MsalError.NoTokensFoundError, ex.ErrorCode);
109111

110112
// B can resume acquiring tokens silently via the normal RT, after a non
111113
await InteractiveAsync(_appB, ServerTokenResponse.NonFociToken).ConfigureAwait(false);
@@ -115,6 +117,37 @@ public async Task FociAndNonFociAppsCoexistAsync()
115117
await AssertAccountsAsync().ConfigureAwait(false);
116118
AssertAppHasRT(_appB);
117119
AssertFRTExists();
120+
}
121+
}
122+
123+
/// <summary>
124+
/// A and B are in the family. When B tries to refresh the FRT, an error occurs (e.g. MFA required).
125+
/// This error has to be surfaced. This is a different scenario than receiving "client_mismatch" error
126+
/// </summary>
127+
[TestMethod]
128+
[WorkItem(1067)] // https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/issues/1067
129+
public async Task FociDoesNotHideRTRefreshErrorsAsync()
130+
{
131+
using (_harness = new MockHttpAndServiceBundle())
132+
{
133+
InitApps();
134+
135+
// Act
136+
await InteractiveAsync(_appA, ServerTokenResponse.FociToken).ConfigureAwait(false);
137+
138+
// B cannot acquire a token interactivelty, but will try to use FRT
139+
var ex = AssertException.TaskThrows<MsalUiRequiredException>(() => SilentAsync(_appB, ServerTokenResponse.OtherError));
140+
Assert.AreEqual(MsalError.InvalidGrantError, ex.ErrorCode);
141+
Assert.IsTrue(!String.IsNullOrEmpty(ex.CorrelationId));
142+
143+
// B performs interactive auth and everything goes back to normal - both A and B can silently sing in
144+
await InteractiveAsync(_appB, ServerTokenResponse.FociToken).ConfigureAwait(false);
145+
await SilentAsync(_appB, ServerTokenResponse.FociToken).ConfigureAwait(false);
146+
await SilentAsync(_appA, ServerTokenResponse.FociToken).ConfigureAwait(false);
147+
148+
// Assert
149+
await AssertAccountsAsync().ConfigureAwait(false);
150+
AssertFRTExists();
118151

119152
}
120153
}
@@ -165,7 +198,7 @@ public async Task FociAppLeavesFamilyAsync()
165198
await SilentAsync(_appB, ServerTokenResponse.FociToken).ConfigureAwait(false);
166199

167200
// B leaves the family -> STS will not refresh its token based on the FRT
168-
AssertException.TaskThrows<MsalUiRequiredException>(() => SilentAsync(_appB, ServerTokenResponse.Error));
201+
AssertException.TaskThrows<MsalUiRequiredException>(() => SilentAsync(_appB, ServerTokenResponse.ErrorClientMismatch));
169202

170203
// B can resume acquiring tokens silently via the normal RT, after an interactive flow
171204
await InteractiveAsync(_appB, ServerTokenResponse.NonFociToken).ConfigureAwait(false);
@@ -208,8 +241,8 @@ private async Task SilentAsync(
208241
{
209242
ExpectedMethod = HttpMethod.Post,
210243
ResponseMessage =
211-
(serverTokenResponse == ServerTokenResponse.Error) ?
212-
MockHelpers.CreateInvalidGrantTokenResponseMessage() :
244+
IsError(serverTokenResponse) ?
245+
MockHelpers.CreateInvalidGrantTokenResponseMessage(GetSubError(serverTokenResponse)) :
213246
MockHelpers.CreateSuccessTokenResponseMessage(
214247
MsalTestConstants.UniqueId,
215248
MsalTestConstants.DisplayableId,
@@ -227,9 +260,30 @@ private async Task SilentAsync(
227260

228261
}
229262

263+
private static string GetSubError(ServerTokenResponse response)
264+
{
265+
if (response == ServerTokenResponse.ErrorClientMismatch)
266+
{
267+
return "client_mismatch";
268+
}
269+
270+
if (response == ServerTokenResponse.OtherError)
271+
{
272+
return null;
273+
}
274+
275+
throw new InvalidOperationException("Test error: response type is not an error");
276+
}
277+
278+
private static bool IsError(ServerTokenResponse response)
279+
{
280+
return response == ServerTokenResponse.ErrorClientMismatch ||
281+
response == ServerTokenResponse.OtherError;
282+
}
283+
230284
private async Task InteractiveAsync(PublicClientApplication app, ServerTokenResponse serverTokenResponse)
231285
{
232-
if (serverTokenResponse == ServerTokenResponse.Error)
286+
if (serverTokenResponse == ServerTokenResponse.ErrorClientMismatch)
233287
{
234288
throw new NotImplementedException("test error");
235289
}

0 commit comments

Comments
 (0)