|
7 | 7 | using System.Runtime.CompilerServices; |
8 | 8 | using System.Threading; |
9 | 9 | using System.Threading.Tasks; |
| 10 | +using Microsoft.Identity.Client.Core; |
10 | 11 | using Microsoft.Identity.Client.OAuth2; |
11 | 12 |
|
12 | 13 | namespace Microsoft.Identity.Client.Extensibility |
@@ -42,6 +43,132 @@ public static AbstractAcquireTokenParameterBuilder<T> OnBeforeTokenRequest<T>( |
42 | 43 | return builder; |
43 | 44 | } |
44 | 45 |
|
| 46 | + /// <summary> |
| 47 | + /// Sends <paramref name="attributeTokens"/> as the <c>attribute_tokens</c> body parameter |
| 48 | + /// and includes them in the cache key. Null/empty/whitespace entries are ignored; |
| 49 | + /// a null or empty collection is a no-op. |
| 50 | + /// </summary> |
| 51 | + /// <typeparam name="T">The concrete confidential client builder type.</typeparam> |
| 52 | + /// <param name="builder">The builder to chain options to.</param> |
| 53 | + /// <param name="attributeTokens">Attribute tokens to include. Individual tokens must not contain whitespace.</param> |
| 54 | + /// <returns>The builder to chain method calls.</returns> |
| 55 | + /// <remarks> |
| 56 | + /// <para> |
| 57 | + /// For <c>AcquireTokenForClient</c> the <c>attribute_tokens</c> set is part of the cache |
| 58 | + /// partition key, so different sets are fully isolated. |
| 59 | + /// </para> |
| 60 | + /// <para> |
| 61 | + /// For <c>AcquireTokenOnBehalfOf</c> the partition key is the user assertion hash; the |
| 62 | + /// <c>attribute_tokens</c> set is stored as an item-level cache component within that |
| 63 | + /// partition. Multiple variants for the same assertion coexist and are disambiguated on |
| 64 | + /// read only when the caller supplies the same <c>WithAttributeTokens</c> set. |
| 65 | + /// <b>Mixing attributed and non-attributed reads against the same user assertion can |
| 66 | + /// return unintended cache entries or fail with <c>multiple_matching_tokens_detected</c>.</b> |
| 67 | + /// Be consistent: if you used <c>WithAttributeTokens</c> on a write, use it on every |
| 68 | + /// subsequent read for that assertion. Callers that require strict per-set cache isolation |
| 69 | + /// across different attribute-token sets should use separate <see cref="IConfidentialClientApplication"/> |
| 70 | + /// instances. |
| 71 | + /// </para> |
| 72 | + /// </remarks> |
| 73 | + /// <exception cref="ArgumentException">Thrown when any token contains embedded whitespace.</exception> |
| 74 | + /// <exception cref="MsalClientException"> |
| 75 | + /// Thrown when the application was not configured to allow experimental features |
| 76 | + /// (this method transitively calls <see cref="WithExtraBodyParameters{T}"/>, which requires |
| 77 | + /// experimental features to be enabled via <c>WithExperimentalFeatures()</c> on the application builder). |
| 78 | + /// </exception> |
| 79 | + public static T WithAttributeTokens<T>( |
| 80 | + this AbstractConfidentialClientAcquireTokenParameterBuilder<T> builder, |
| 81 | + IEnumerable<string> attributeTokens) |
| 82 | + where T : AbstractConfidentialClientAcquireTokenParameterBuilder<T> |
| 83 | + { |
| 84 | + ILoggerAdapter logger = builder.ServiceBundle?.ApplicationLogger; |
| 85 | + |
| 86 | + if (attributeTokens is null) |
| 87 | + { |
| 88 | + logger?.Verbose(() => "[WithAttributeTokens] No attribute tokens passed."); |
| 89 | + return (T)builder; |
| 90 | + } |
| 91 | + |
| 92 | + var normalizedTokens = new List<string>(); |
| 93 | + foreach (string token in attributeTokens) |
| 94 | + { |
| 95 | + if (!string.IsNullOrWhiteSpace(token)) |
| 96 | + { |
| 97 | + string trimmed = token.Trim(); |
| 98 | + if (trimmed.Any(char.IsWhiteSpace)) |
| 99 | + { |
| 100 | + throw new ArgumentException( |
| 101 | + $"Attribute tokens must not contain whitespace. Invalid token: '{trimmed}'", |
| 102 | + nameof(attributeTokens)); |
| 103 | + } |
| 104 | + |
| 105 | + normalizedTokens.Add(trimmed); |
| 106 | + } |
| 107 | + } |
| 108 | + |
| 109 | + if (normalizedTokens.Count == 0) |
| 110 | + { |
| 111 | + logger?.Verbose(() => "[WithAttributeTokens] collection contained no usable tokens."); |
| 112 | + return (T)builder; |
| 113 | + } |
| 114 | + |
| 115 | + // Deduplicate and sort so that callers passing the same logical set of tokens |
| 116 | + // (in any order, with possible duplicates) hit the same cache entry and |
| 117 | + // produce the same request body. |
| 118 | + normalizedTokens = normalizedTokens.Distinct(StringComparer.Ordinal).ToList(); |
| 119 | + normalizedTokens.Sort(StringComparer.Ordinal); |
| 120 | + |
| 121 | + string joinedTokens = string.Join(" ", normalizedTokens); |
| 122 | + |
| 123 | + int count = normalizedTokens.Count; |
| 124 | + logger?.Info(() => $"[WithAttributeTokens] Attaching {count} attribute token(s) " + |
| 125 | + "to the request body and including them in the cache key partition."); |
| 126 | + |
| 127 | + var extraBodyParams = new Dictionary<string, Func<CancellationToken, Task<string>>> |
| 128 | + { |
| 129 | + { OAuth2Parameter.AttributeTokens, _ => Task.FromResult(joinedTokens) } |
| 130 | + }; |
| 131 | + |
| 132 | + return builder.WithExtraBodyParameters(extraBodyParams); |
| 133 | + } |
| 134 | + |
| 135 | + /// <summary> |
| 136 | + /// Add extra body parameters to the token request. These parameters are added to the cache key |
| 137 | + /// to associate these parameters with the acquired token. Works for confidential client flows |
| 138 | + /// (AcquireTokenForClient, AcquireTokenOnBehalfOf, AcquireTokenByAuthorizationCode). |
| 139 | + /// </summary> |
| 140 | + /// <typeparam name="T">The concrete confidential client builder type.</typeparam> |
| 141 | + /// <param name="builder">The builder to chain options to.</param> |
| 142 | + /// <param name="extraBodyParams">List of additional body parameters.</param> |
| 143 | + /// <returns>The concrete builder to chain method calls.</returns> |
| 144 | + public static T WithExtraBodyParameters<T>( |
| 145 | + this AbstractConfidentialClientAcquireTokenParameterBuilder<T> builder, |
| 146 | + Dictionary<string, Func<CancellationToken, Task<string>>> extraBodyParams) |
| 147 | + where T : AbstractConfidentialClientAcquireTokenParameterBuilder<T> |
| 148 | + { |
| 149 | + builder.ValidateUseOfExperimentalFeature(); |
| 150 | + |
| 151 | + if (extraBodyParams == null || extraBodyParams.Count == 0) |
| 152 | + { |
| 153 | + return (T)builder; |
| 154 | + } |
| 155 | + |
| 156 | + builder.OnBeforeTokenRequest(async data => |
| 157 | + { |
| 158 | + foreach (var param in extraBodyParams) |
| 159 | + { |
| 160 | + if (param.Value != null) |
| 161 | + { |
| 162 | + data.BodyParameters.Add(param.Key, await param.Value(data.CancellationToken).ConfigureAwait(false)); |
| 163 | + } |
| 164 | + } |
| 165 | + }); |
| 166 | + |
| 167 | + builder.WithAdditionalCacheKeyComponents(extraBodyParams); |
| 168 | + |
| 169 | + return (T)builder; |
| 170 | + } |
| 171 | + |
45 | 172 | /// <summary> |
46 | 173 | /// Binds the token to a key in the cache.No cryptographic operations is performed on the token. |
47 | 174 | /// </summary> |
|
0 commit comments