Skip to content

Commit 6917b4a

Browse files
Merge branch 'main' into nebharg/fic-otel-enricher
2 parents eafba37 + 06ae3da commit 6917b4a

6 files changed

Lines changed: 539 additions & 7 deletions

File tree

docs/sni_mtls_pop_token_design.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,6 @@ IConfidentialClientApplication app = ConfidentialClientApplicationBuilder.Create
8888
.WithAuthority(authority)
8989
.WithAzureRegion("westus")
9090
.WithCertificate(certificate, true)
91-
.WithExperimentalFeatures(true)
9291
.Build();
9392

9493
AuthenticationResult result = await app.AcquireTokenForClient(scopes).WithMtlsProofOfPossession()

src/client/Microsoft.Identity.Client/Internal/Logger/LoggerHelper.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ public static ILoggerAdapter CreateLogger(
7676

7777
public static string FormatLogMessage(string message, bool piiEnabled, string correlationId, string clientInformation)
7878
{
79-
return string.Format(
79+
string formattedMessage = string.Format(
8080
CultureInfo.InvariantCulture,
8181
"{0} MSAL {1} {2} {3} {4} [{5}{6}]{7} {8}",
8282
piiEnabled,
@@ -88,6 +88,8 @@ public static string FormatLogMessage(string message, bool piiEnabled, string co
8888
correlationId,
8989
clientInformation,
9090
message);
91+
92+
return formattedMessage;
9193
}
9294

9395
internal static string GetPiiScrubbedExceptionDetails(Exception ex)

src/client/Microsoft.Identity.Client/Internal/Logger/MsalLoggerExtensions.cs

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,12 @@ public static void Error(this ILoggerAdapter logger, string message)
3030

3131
public static void ErrorPiiWithPrefix(this ILoggerAdapter logger, Exception exWithPii, string prefix)
3232
{
33-
logger.Log(LogLevel.Error, prefix + exWithPii, prefix + LoggerHelper.GetPiiScrubbedExceptionDetails(exWithPii));
33+
if (!logger.IsLoggingEnabled(LogLevel.Error))
34+
{
35+
return;
36+
}
37+
38+
logger.Log(LogLevel.Error, prefix + TokenScrubber.Scrub(exWithPii?.ToString()), prefix + LoggerHelper.GetPiiScrubbedExceptionDetails(exWithPii));
3439
}
3540

3641
public static void ErrorPii(this ILoggerAdapter logger, string messageWithPii, string messageScrubbed)
@@ -40,7 +45,12 @@ public static void ErrorPii(this ILoggerAdapter logger, string messageWithPii, s
4045

4146
public static void ErrorPii(this ILoggerAdapter logger, Exception exWithPii)
4247
{
43-
logger.Log(LogLevel.Error, exWithPii.ToString(), LoggerHelper.GetPiiScrubbedExceptionDetails(exWithPii));
48+
if (!logger.IsLoggingEnabled(LogLevel.Error))
49+
{
50+
return;
51+
}
52+
53+
logger.Log(LogLevel.Error, TokenScrubber.Scrub(exWithPii?.ToString()), LoggerHelper.GetPiiScrubbedExceptionDetails(exWithPii));
4454
}
4555

4656
public static void Warning(this ILoggerAdapter logger, string message)
@@ -55,12 +65,22 @@ public static void WarningPii(this ILoggerAdapter logger, string messageWithPii,
5565

5666
public static void WarningPii(this ILoggerAdapter logger, Exception exWithPii)
5767
{
58-
logger.Log(LogLevel.Warning, exWithPii.ToString(), LoggerHelper.GetPiiScrubbedExceptionDetails(exWithPii));
68+
if (!logger.IsLoggingEnabled(LogLevel.Warning))
69+
{
70+
return;
71+
}
72+
73+
logger.Log(LogLevel.Warning, TokenScrubber.Scrub(exWithPii?.ToString()), LoggerHelper.GetPiiScrubbedExceptionDetails(exWithPii));
5974
}
6075

6176
public static void WarningPiiWithPrefix(this ILoggerAdapter logger, Exception exWithPii, string prefix)
6277
{
63-
logger.Log(LogLevel.Warning, prefix + exWithPii, prefix + LoggerHelper.GetPiiScrubbedExceptionDetails(exWithPii));
78+
if (!logger.IsLoggingEnabled(LogLevel.Warning))
79+
{
80+
return;
81+
}
82+
83+
logger.Log(LogLevel.Warning, prefix + TokenScrubber.Scrub(exWithPii?.ToString()), prefix + LoggerHelper.GetPiiScrubbedExceptionDetails(exWithPii));
6484
}
6585

6686
public static void Info(this ILoggerAdapter logger, string message)
@@ -97,7 +117,12 @@ public static void InfoPii(this ILoggerAdapter logger, Func<string> messageWithP
97117

98118
public static void InfoPii(this ILoggerAdapter logger, Exception exWithPii)
99119
{
100-
logger.Log(LogLevel.Info, exWithPii?.ToString(), LoggerHelper.GetPiiScrubbedExceptionDetails(exWithPii));
120+
if (!logger.IsLoggingEnabled(LogLevel.Info))
121+
{
122+
return;
123+
}
124+
125+
logger.Log(LogLevel.Info, TokenScrubber.Scrub(exWithPii?.ToString()), LoggerHelper.GetPiiScrubbedExceptionDetails(exWithPii));
101126
}
102127

103128
public static void Verbose(this ILoggerAdapter logger, Func<string> messageProducer)
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
using System;
5+
using System.Text;
6+
7+
namespace Microsoft.Identity.Client.Internal.Logger
8+
{
9+
/// <summary>
10+
/// Redacts opaque tokens that eSTS/CCS have tagged with a Highly Identifiable Token (HIT) tag
11+
/// from MSAL's own log output before the text reaches any logging sink.
12+
/// </summary>
13+
/// <remarks>
14+
/// eSTS/CCS embed the "EvoStsArtifacts" marker (MSA uses the literal "MsaArtifacts") inside the
15+
/// base64/base64url encoded token. Because the marker can appear at any of three byte-offset
16+
/// alignments once base64 encoded, all three renderings are matched. The marker sits between the
17+
/// token header and body, so a match is expanded both left and right over the token charset to
18+
/// cover the full token run before replacing it with <see cref="Placeholder"/>.
19+
/// </remarks>
20+
internal static class TokenScrubber
21+
{
22+
internal const string Placeholder = "[Redacted opaque token]";
23+
24+
private const string DisableSwitchName = "Microsoft.Identity.Client.DisableOpaqueTokenScrubbing";
25+
26+
// Base64 renderings of the "EvoStsArtifacts" prefix at the three possible byte-offset
27+
// alignments, plus the MSA literal "MsaArtifacts". Ordinal, case-sensitive.
28+
private static readonly string[] s_patterns = new[]
29+
{
30+
"RXZvU3RzQXJ0aWZhY3Rz", // offset 0
31+
"V2b1N0c0FydGlmYWN0c", // offset 1
32+
"dm9TdHNBcnRpZmFjdH", // offset 2
33+
"MsaArtifacts", // MSA literal
34+
};
35+
36+
private static readonly bool s_disabled = AppContext.TryGetSwitch(DisableSwitchName, out bool isDisabled) && isDisabled;
37+
38+
/// <summary>
39+
/// Redacts any HIT-tagged opaque tokens found in <paramref name="message"/>.
40+
/// </summary>
41+
/// <param name="message">The log message that may contain a tagged opaque token.</param>
42+
/// <returns>
43+
/// The same string reference when nothing was redacted (fast path, no allocation); otherwise a new
44+
/// string with each tagged token run replaced by <see cref="Placeholder"/>.
45+
/// </returns>
46+
/// <example>
47+
/// <code>
48+
/// string scrubbed = TokenScrubber.Scrub("Set-Cookie: esctx-x=AQAB...RXZvU3RzQXJ0aWZhY3Rz...; path=/");
49+
/// // scrubbed == "Set-Cookie: [Redacted opaque token]; path=/"
50+
/// </code>
51+
/// </example>
52+
public static string Scrub(string message)
53+
{
54+
if (s_disabled || string.IsNullOrEmpty(message))
55+
{
56+
return message;
57+
}
58+
59+
// Single search for the first match. On the no-match fast path this returns the same
60+
// reference with no allocation and without scanning the string twice.
61+
int matchStart = FindEarliestMatch(message, 0, out int matchLength);
62+
if (matchStart < 0)
63+
{
64+
return message;
65+
}
66+
67+
var sb = new StringBuilder(message.Length);
68+
int index = 0;
69+
70+
while (matchStart >= 0)
71+
{
72+
// Expand the match left and right over the token charset to cover the whole run.
73+
int runStart = matchStart;
74+
while (runStart > index && IsTokenChar(message[runStart - 1]))
75+
{
76+
runStart--;
77+
}
78+
79+
int runEnd = matchStart + matchLength; // exclusive
80+
while (runEnd < message.Length && IsTokenChar(message[runEnd]))
81+
{
82+
runEnd++;
83+
}
84+
85+
// Append text before the run, then the placeholder.
86+
sb.Append(message, index, runStart - index);
87+
sb.Append(Placeholder);
88+
89+
index = runEnd;
90+
matchStart = index < message.Length ? FindEarliestMatch(message, index, out matchLength) : -1;
91+
}
92+
93+
if (index < message.Length)
94+
{
95+
sb.Append(message, index, message.Length - index);
96+
}
97+
98+
return sb.ToString();
99+
}
100+
101+
private static int FindEarliestMatch(string message, int startIndex, out int matchLength)
102+
{
103+
int earliest = -1;
104+
matchLength = 0;
105+
106+
for (int i = 0; i < s_patterns.Length; i++)
107+
{
108+
int found = message.IndexOf(s_patterns[i], startIndex, StringComparison.Ordinal);
109+
if (found >= 0 && (earliest < 0 || found < earliest))
110+
{
111+
earliest = found;
112+
matchLength = s_patterns[i].Length;
113+
}
114+
}
115+
116+
return earliest;
117+
}
118+
119+
private static bool IsTokenChar(char c)
120+
{
121+
// base64 + base64url + '=' padding: [A-Za-z0-9+/_-=]
122+
return (c >= 'A' && c <= 'Z') ||
123+
(c >= 'a' && c <= 'z') ||
124+
(c >= '0' && c <= '9') ||
125+
c == '+' || c == '/' || c == '_' || c == '-' || c == '=';
126+
}
127+
}
128+
}

tests/Microsoft.Identity.Test.Unit/PublicApiTests/LoggerTests.cs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,86 @@ private void AfterCacheAccessWithLogging(TokenCacheNotificationArgs args)
440440

441441
args.IdentityLogger.Log(entry);
442442
}
443+
444+
private const string TaggedOpaqueToken = "AQABCQEAAAAdDD7nRXZvU3RzQXJ0aWZhY3RzDQAAAAAAwEvgQ_kqa0hyAA";
445+
446+
private static string GetPiiLoggedOutput(Action<ILoggerAdapter> logAction)
447+
{
448+
var testLogger = new TestIdentityLogger();
449+
ILoggerAdapter logger = new IdentityLoggerAdapter(testLogger, Guid.Empty, null, null, enablePiiLogging: true);
450+
logAction(logger);
451+
return testLogger.StringBuilder.ToString();
452+
}
453+
454+
[TestMethod]
455+
[Description("Exception messages that carry an ESTS-tagged opaque token must be scrubbed before logging.")]
456+
public void ErrorPii_Exception_WithTaggedToken_IsScrubbed()
457+
{
458+
// Arrange
459+
var exception = new MsalServiceException("some_error", "server rejected the request")
460+
{
461+
ResponseBody = "{\"error\":\"invalid_grant\",\"opaque\":\"" + TaggedOpaqueToken + "\"}"
462+
};
463+
464+
// Act
465+
string output = GetPiiLoggedOutput(logger => logger.ErrorPii(exception));
466+
467+
// Assert
468+
Assert.Contains(TokenScrubber.Placeholder, output);
469+
Assert.DoesNotContain("RXZvU3RzQXJ0aWZhY3Rz", output);
470+
Assert.DoesNotContain(TaggedOpaqueToken, output);
471+
}
472+
473+
[TestMethod]
474+
[Description("Info-level exception logging must also scrub ESTS-tagged opaque tokens.")]
475+
public void InfoPii_Exception_WithTaggedToken_IsScrubbed()
476+
{
477+
// Arrange
478+
var exception = new MsalServiceException("some_error", "server rejected the request")
479+
{
480+
ResponseBody = "{\"opaque\":\"" + TaggedOpaqueToken + "\"}"
481+
};
482+
483+
// Act
484+
string output = GetPiiLoggedOutput(logger => logger.InfoPii(exception));
485+
486+
// Assert
487+
Assert.Contains(TokenScrubber.Placeholder, output);
488+
Assert.DoesNotContain("RXZvU3RzQXJ0aWZhY3Rz", output);
489+
}
490+
491+
[TestMethod]
492+
[Description("Warning-level exception logging must also scrub ESTS-tagged opaque tokens.")]
493+
public void WarningPii_Exception_WithTaggedToken_IsScrubbed()
494+
{
495+
// Arrange
496+
var exception = new MsalServiceException("some_error", "server rejected the request")
497+
{
498+
ResponseBody = "{\"opaque\":\"" + TaggedOpaqueToken + "\"}"
499+
};
500+
501+
// Act
502+
string output = GetPiiLoggedOutput(logger => logger.WarningPii(exception));
503+
504+
// Assert
505+
Assert.Contains(TokenScrubber.Placeholder, output);
506+
Assert.DoesNotContain("RXZvU3RzQXJ0aWZhY3Rz", output);
507+
}
508+
509+
[TestMethod]
510+
[Description("Tactical scope: ordinary (non-exception, non-ESTS) log messages are NOT scrubbed.")]
511+
public void InfoPii_PlainMessage_WithTaggedToken_IsNotScrubbed()
512+
{
513+
// Arrange
514+
string message = "diagnostic value " + TaggedOpaqueToken;
515+
516+
// Act
517+
string output = GetPiiLoggedOutput(logger => logger.InfoPii(message, string.Empty));
518+
519+
// Assert
520+
Assert.Contains(TaggedOpaqueToken, output);
521+
Assert.DoesNotContain(TokenScrubber.Placeholder, output);
522+
}
443523

444524
}
445525
}

0 commit comments

Comments
 (0)