diff --git a/Documentation/ReleaseNotes/Corvus.Identity.v3.md b/Documentation/ReleaseNotes/Corvus.Identity.v3.md index f42e431..40443ad 100644 --- a/Documentation/ReleaseNotes/Corvus.Identity.v3.md +++ b/Documentation/ReleaseNotes/Corvus.Identity.v3.md @@ -1,5 +1,11 @@ # Release notes for Corvus.Identity v3. +## v3.1 + +New features: + +The `IAccessTokenSource` adapter for `Azure.Core`-style `TokenCredentials` (and, by extension, also the `Microsoft.Rest` adapters) now do token caching. This means for for credential types where the underlying mechanism does not have built-in caching (most notably Azure Managed Identities), and the client code also doesn't do any credential caching (which can happen with some Autorest client usage styles) we will now typically be able to avoid calls to the underlying credential provider in cases where the most recently received token for a given scope hasn't expired yet. + ## v3.0 New features: diff --git a/GitVersion.yml b/GitVersion.yml index 2f5064d..d6d4eed 100644 --- a/GitVersion.yml +++ b/GitVersion.yml @@ -24,5 +24,5 @@ branches: mode: ContinuousDeployment tag: alpha regex: ^develop/\d\.\d$ -next-version: "3.0" +next-version: "3.1" diff --git a/Solutions/Corvus.Identity.Azure/Corvus.Identity.Azure.csproj b/Solutions/Corvus.Identity.Azure/Corvus.Identity.Azure.csproj index fd8228c..216a2cf 100644 --- a/Solutions/Corvus.Identity.Azure/Corvus.Identity.Azure.csproj +++ b/Solutions/Corvus.Identity.Azure/Corvus.Identity.Azure.csproj @@ -17,7 +17,7 @@ - + all diff --git a/Solutions/Corvus.Identity.Azure/Corvus/Identity/ClientAuthentication/Azure/Internal/AzureTokenCredentialAccessTokenSource.cs b/Solutions/Corvus.Identity.Azure/Corvus/Identity/ClientAuthentication/Azure/Internal/AzureTokenCredentialAccessTokenSource.cs index 475eaa1..cb5508c 100644 --- a/Solutions/Corvus.Identity.Azure/Corvus/Identity/ClientAuthentication/Azure/Internal/AzureTokenCredentialAccessTokenSource.cs +++ b/Solutions/Corvus.Identity.Azure/Corvus/Identity/ClientAuthentication/Azure/Internal/AzureTokenCredentialAccessTokenSource.cs @@ -9,6 +9,7 @@ namespace Corvus.Identity.ClientAuthentication.Azure.Internal using System.Threading.Tasks; using global::Azure.Core; + using global::Azure.Core.Pipeline; /// /// Wrapper for an that implements @@ -16,7 +17,9 @@ namespace Corvus.Identity.ClientAuthentication.Azure.Internal /// internal class AzureTokenCredentialAccessTokenSource : IAccessTokenSource { + private readonly object sync = new (); private readonly IAzureTokenCredentialSource tokenCredentialSource; + private Task? cacheAdapterTask; /// /// Creates a . @@ -35,12 +38,21 @@ public async ValueTask GetAccessTokenAsync( { try { - TokenCredential tokenCredential = await this.tokenCredentialSource - .GetTokenCredentialAsync(cancellationToken) - .ConfigureAwait(false); - return await GetAccessTokenFromTokenCredentialAsync( - requiredTokenCharacteristics, tokenCredential, cancellationToken) - .ConfigureAwait(false); + Task cacheAdapterTask; + lock (this.sync) + { + if (this.cacheAdapterTask?.IsFaulted != false) + { + // Either we haven't built this task yet, or we have but it failed. + this.cacheAdapterTask = this.GetAdapter(replace: false, cancellationToken); + } + + cacheAdapterTask = this.cacheAdapterTask; + } + + TokenCacheAdapter cacheAdapter = await cacheAdapterTask.ConfigureAwait(false); + AccessToken result = await cacheAdapter.GetAccessTokenAsync(requiredTokenCharacteristics).ConfigureAwait(false); + return new AccessTokenDetail(result.Token, result.ExpiresOn); } catch (Exception x) { @@ -55,12 +67,23 @@ public async ValueTask GetReplacementForFailedAccessTokenAsyn { try { - TokenCredential tokenCredential = await this.tokenCredentialSource - .GetReplacementForFailedTokenCredentialAsync(cancellationToken) - .ConfigureAwait(false); - return await GetAccessTokenFromTokenCredentialAsync( - requiredTokenCharacteristics, tokenCredential, cancellationToken) - .ConfigureAwait(false); + Task cacheAdapterTask = this.GetAdapter(replace: true, cancellationToken); + TokenCacheAdapter cacheAdapter = await cacheAdapterTask.ConfigureAwait(false); + + // We only update the task if the attempt to get a new adapter succeeded. Some + // IAzureTokenCredentialSource implementations don't support replacement (e.g., + // because a specific TokenCredential was supplied at application startup. + // Attempts to refresh will inevitably fail with those, at which point we're + // better off keeping hold of the cache we already had: if the situation that + // prompted the application to attempt a replacement turns out to be some + // transient external condition, it should recover. + lock (this.sync) + { + this.cacheAdapterTask = cacheAdapterTask; + } + + AccessToken result = await cacheAdapter.GetAccessTokenAsync(requiredTokenCharacteristics).ConfigureAwait(false); + return new AccessTokenDetail(result.Token, result.ExpiresOn); } catch (Exception x) { @@ -68,19 +91,131 @@ public async ValueTask GetReplacementForFailedAccessTokenAsyn } } - private static async ValueTask GetAccessTokenFromTokenCredentialAsync( - AccessTokenRequest requiredTokenCharacteristics, - TokenCredential tokenCredential, + // The Task returned is awaited multiple times, so we can't use ValueTask. + private async Task GetAdapter( + bool replace, CancellationToken cancellationToken) { - AccessToken result = await tokenCredential.GetTokenAsync( - new TokenRequestContext( + TokenCredential tokenCredential = await (replace +#pragma warning disable CA2012 // Use ValueTasks correctly - overzealous analyzer + ? this.tokenCredentialSource.GetReplacementForFailedTokenCredentialAsync(cancellationToken) + : this.tokenCredentialSource.GetTokenCredentialAsync(cancellationToken)) +#pragma warning restore CA2012 + .ConfigureAwait(false); + + return new TokenCacheAdapter(tokenCredential); + } + + /// + /// Enables use of Azure.Core's token cache. + /// + /// + /// + /// Azure.Core provides built-in token caching functionality that all modern Azure SDK + /// clients get to take advantage of. However, this cache is not exposed as a distinct + /// feature: it's part of Azure.Core's HTTP pipeline system. This is fine for code that + /// has gone all-in on Azure.Core (e.g., all new Azure SDK client libraries), because they + /// will be using the pipeline for all their communications, so they'll pick up the token + /// caching functionality automatically. + /// + /// + /// The picture is less rosy for code that isn't using the Azure.Core HTTP pipeline. Any + /// code using is unlikely to be using that pipeline, + /// because it would almost certainly be using + /// instead. Note that anything using the Microsoft.Rest adapter will be using + /// indirectly, so this applies to anything using the + /// v3 Corvus.Identity libraries with old-style clients, such as older Azure client SDKs, + /// or Autorest-generated client libraries. Code working directly with access tokens + /// outwith the Azure.Core pipeline is, according to the Azure.Core documentation, on + /// its own for token cache and refresh purposes. This is unfortunate because there's + /// a fair amount of work involved in getting this right, and Microsoft has already done + /// all of the necessary work in Azure.Core, it's just buried in a private nested class. + /// + /// + /// This class exploits the fact that although the Azure.Core's caching is not public, + /// it is possible to access it through a public class. The cache is implemented as a + /// private nested class in , and while + /// we can't use that private nested class directly, its contains class is public. + /// By deriving from , we get access to + /// protected methods that will then go on to use the private nested access token + /// cache for us. + /// + /// + /// This is slightly messy because expects + /// to be used as part of an Azure.Core HTTP pipeline. In practice this means that it + /// doesn't provide a direct way to obtain the access tokens: instead, it expects to be + /// passed an , which it will then populate with a suitable + /// header. So we have to fake one of these up, pass that to our base class, and then + /// dig out the token it set. + /// + /// + private class TokenCacheAdapter : BearerTokenAuthenticationPolicy + { + private readonly HttpPipeline fakePipeline; + + public TokenCacheAdapter(TokenCredential credential) + : base(credential, Array.Empty()) + { + this.fakePipeline = HttpPipelineBuilder.Build(new FakePipelineOptions(), this); + } + + public async ValueTask GetAccessTokenAsync(AccessTokenRequest requiredTokenCharacteristics) + { + HttpMessage message = this.fakePipeline.CreateMessage(); + var requestContext = new TokenRequestContext( scopes: requiredTokenCharacteristics.Scopes, claims: requiredTokenCharacteristics.Claims, - tenantId: requiredTokenCharacteristics.AuthorityId), - cancellationToken) - .ConfigureAwait(false); - return new AccessTokenDetail(result.Token, result.ExpiresOn); + tenantId: requiredTokenCharacteristics.AuthorityId); + await this.AuthenticateAndAuthorizeRequestAsync(message, requestContext).ConfigureAwait(false); + if (!message.Request.Headers.TryGetValue(HttpHeader.Names.Authorization, out string? headerValue)) + { + throw new InvalidOperationException("Token cache adapter failed to get token"); + } + + if (headerValue.StartsWith("Bearer ")) + { + headerValue = headerValue[7..]; + } + + // Unfortnately, although the base class's token cache knows the expiration time, + // there's no way to get it to reveal that, so we have to make something up. + // The token cache proactively refreshes tokens ahead of their expiration, so + // in general it tries never to give back a token that has less than 5 minutes + // left, so if we report that it has 3 minutes left, it's unlikely to expire before + // that. Of course, most of the time it will actually have much longer than that + // left to live, but the effect will be simply that code looking at the expiration + // time will ask for a new token when it thinks this one has expired, and we'll + // forward that call into the cache, which will inspect the real expiration time, + // and determine that it can return the same token without needing to fetch a + // new one. So the worst case is that we might end up causing more calls into the + // cache than would otherwise happen. But in practice, the reason for adding this + // cache adapter is that we observe that Autorest-generated clients make no attempt + // cache the token we return to them, and ask for a new one frequently in any case, + // so frequent calls into the cache are likely to happen regardless of what + // expiration time we report. In any case when the base class is used in as part of + // an Azure.Core HTTP pipeline (i.e., the scenario for which it is designed), it + // calls into the cache for each request, so it's designed to cope with that. + return new AccessToken(headerValue, DateTimeOffset.UtcNow.AddMinutes(3)); + } + + /// + /// A concrete class to keep + /// happy. + /// + /// + /// + /// + /// requires us to pass a non-null . And since that's an + /// abstract type, we need a concrete type to be able to construct one. This isn't + /// used because we're only using the HTTP pipeline we build as a factory for + /// s. Since we never really use the pipeline properly, + /// the client options serve no purpose. We only need it because we have to fool + /// our base class into thinking there is a pipeline so that we can use its cache. + /// + /// + private class FakePipelineOptions : ClientOptions + { + } } } } \ No newline at end of file diff --git a/Solutions/Corvus.Identity.Examples.AzureFunctions/packages.lock.json b/Solutions/Corvus.Identity.Examples.AzureFunctions/packages.lock.json index 27856bc..dac8e2f 100644 --- a/Solutions/Corvus.Identity.Examples.AzureFunctions/packages.lock.json +++ b/Solutions/Corvus.Identity.Examples.AzureFunctions/packages.lock.json @@ -49,29 +49,29 @@ }, "Azure.Core": { "type": "Transitive", - "resolved": "1.20.0", - "contentHash": "q7xigZIBjLjSKJA/Y+VygmJ2iZGiEyNuicN5iRX9oJL7451SulZm/CQ7qd8YCeL5TgNCNYCIrTIqRaams95zHA==", + "resolved": "1.25.0", + "contentHash": "X8Dd4sAggS84KScWIjEbFAdt2U1KDolQopTPoHVubG2y3CM54f9l6asVrP5Uy384NWXjsspPYaJgz5xHc+KvTA==", "dependencies": { - "Microsoft.Bcl.AsyncInterfaces": "1.0.0", + "Microsoft.Bcl.AsyncInterfaces": "1.1.1", "System.Diagnostics.DiagnosticSource": "4.6.0", "System.Memory.Data": "1.0.2", "System.Numerics.Vectors": "4.5.0", "System.Text.Encodings.Web": "4.7.2", - "System.Text.Json": "4.6.0", + "System.Text.Json": "4.7.2", "System.Threading.Tasks.Extensions": "4.5.4" } }, "Azure.Identity": { "type": "Transitive", - "resolved": "1.5.0", - "contentHash": "VfF88dqrgKXZNOS/y4XrX/jmIfP3pkY+hBUzBNpoKml1nR+QshX6XlXWyToLtWV80TDQ1CmUVCJksktDg5+j1w==", + "resolved": "1.7.0-beta.1", + "contentHash": "VelmyeHyLRcZGMQHFGYNXAGmSWnAmIQ/iB3kk2ERIdAM4JP+H0gmOct2Sy4RHeTHhBsulONMGO/ZsdrLTaOVgQ==", "dependencies": { - "Azure.Core": "1.20.0", - "Microsoft.Identity.Client": "4.30.1", - "Microsoft.Identity.Client.Extensions.Msal": "2.18.4", + "Azure.Core": "1.25.0", + "Microsoft.Identity.Client": "4.46.0", + "Microsoft.Identity.Client.Extensions.Msal": "2.23.0", "System.Memory": "4.5.4", - "System.Security.Cryptography.ProtectedData": "4.5.0", - "System.Text.Json": "4.6.0", + "System.Security.Cryptography.ProtectedData": "4.7.0", + "System.Text.Json": "4.7.2", "System.Threading.Tasks.Extensions": "4.5.4" } }, @@ -373,8 +373,8 @@ }, "Microsoft.Bcl.AsyncInterfaces": { "type": "Transitive", - "resolved": "1.0.0", - "contentHash": "K63Y4hORbBcKLWH5wnKgzyn7TOfYzevIEwIedQHBIkmkEBA9SCqgvom+XTuE+fAFGvINGkhFItaZ2dvMGdT5iw==" + "resolved": "1.1.1", + "contentHash": "yuvf07qFWFqtK3P/MRkEKLhn5r2UbSpVueRziSqj0yJQIKFwG1pq9mOayK3zE5qZCTs0CbrwL9M6R8VwqyGy2w==" }, "Microsoft.Build.Tasks.Git": { "type": "Transitive", @@ -411,8 +411,8 @@ }, "Microsoft.Extensions.Caching.Memory": { "type": "Transitive", - "resolved": "6.0.0", - "contentHash": "Ve3BlCzhAlVp5IgO3+8dacAhZk1A0GlIlFNkAcfR2TfAibLKWIt5DhVJZfu4YtW+XZ89OjYf/agMcgjDtPxdGA==", + "resolved": "6.0.1", + "contentHash": "B4y+Cev05eMcjf1na0v9gza6GUtahXbtY1JCypIgx3B4Ea/KAgsWyXEmW4q6zMbmTMtKzmPVk09rvFJirvMwTg==", "dependencies": { "Microsoft.Extensions.Caching.Abstractions": "6.0.0", "Microsoft.Extensions.DependencyInjection.Abstractions": "6.0.0", @@ -615,18 +615,27 @@ }, "Microsoft.Identity.Client": { "type": "Transitive", - "resolved": "4.30.1", - "contentHash": "xk8tJeGfB2yD3+d7a0DXyV7/HYyEG10IofUHYHoPYKmDbroi/j9t1BqSHgbq1nARDjg7m8Ki6e21AyNU7e/R4Q==" + "resolved": "4.46.0", + "contentHash": "cqNAIELaUypwWvTwnC3MdsccaSpEpVR10WBQ7+e33iSkceeC1kum6aTEwe2m8z4ZdnzlvEMH+dBbXBHMLCy+fQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "6.18.0" + } }, "Microsoft.Identity.Client.Extensions.Msal": { "type": "Transitive", - "resolved": "2.18.4", - "contentHash": "HpG4oLwhQsy0ce7OWq9iDdLtJKOvKRStIKoSEOeBMKuohfuOWNDyhg8fMAJkpG/kFeoe4J329fiMHcJmmB+FPw==", + "resolved": "2.23.0", + "contentHash": "Q8K58FjUIVslHQlk+SIhFYjdy8B1A5Wt3GXxzLS7lnXXaSmbcGzk7d8haqLmR8z/DP99vpZC73SxMa83qSHcbQ==", "dependencies": { - "Microsoft.Identity.Client": "4.30.0", + "Microsoft.Identity.Client": "4.46.0", + "System.IO.FileSystem.AccessControl": "5.0.0", "System.Security.Cryptography.ProtectedData": "4.5.0" } }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "6.18.0", + "contentHash": "ItCO09JoIQr9sY0AumHRLJKToMKM4/jFcBsg3uhKBZZLX1KPxjed/mKrQzo9PXiarfC87rguvFWWg9C996sEqA==" + }, "Microsoft.Net.Http.Headers": { "type": "Transitive", "resolved": "2.1.0", @@ -638,8 +647,8 @@ }, "Microsoft.NETCore.Platforms": { "type": "Transitive", - "resolved": "1.1.0", - "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + "resolved": "5.0.0", + "contentHash": "VyPlqzH2wavqquTcYpkIIAQ6WdenuKoFN0BdYBbCWsclXacSOHNQn66Gt4z5NBqEYW0FAPm5rlvki9ZiCij5xQ==" }, "Microsoft.NETCore.Targets": { "type": "Transitive", @@ -1105,6 +1114,15 @@ "System.Threading.Tasks": "4.3.0" } }, + "System.IO.FileSystem.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "SxHB3nuNrpptVk+vZ/F+7OHEpoHUIKKMl02bUmYHQr1r+glbZQxs7pRtsf4ENO29TVm2TH3AEeep2fJcy92oYw==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, "System.IO.FileSystem.Primitives": { "type": "Transitive", "resolved": "4.3.0", @@ -1406,6 +1424,15 @@ "System.Runtime.Extensions": "4.3.0" } }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, "System.Security.Cryptography.Algorithms": { "type": "Transitive", "resolved": "4.3.0", @@ -1520,8 +1547,8 @@ }, "System.Security.Cryptography.ProtectedData": { "type": "Transitive", - "resolved": "4.5.0", - "contentHash": "wLBKzFnDCxP12VL9ANydSYhk59fC4cvOr9ypYQLPnAj48NQIhqnjdD2yhP8yEKyBJEjERWS9DisKL7rX5eU25Q==" + "resolved": "4.7.0", + "contentHash": "ehYW0m9ptxpGWvE4zgqongBVWpSDU/JCFD4K7krxkQwSz/sFQjEXCUqpvencjy6DYDbn7Ig09R8GFffu8TtneQ==" }, "System.Security.Cryptography.X509Certificates": { "type": "Transitive", @@ -1555,6 +1582,11 @@ "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" } }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, "System.Text.Encoding": { "type": "Transitive", "resolved": "4.3.0", @@ -1583,8 +1615,8 @@ }, "System.Text.Json": { "type": "Transitive", - "resolved": "4.6.0", - "contentHash": "4F8Xe+JIkVoDJ8hDAZ7HqLkjctN/6WItJIzQaifBwClC7wmoLSda/Sv2i6i1kycqDb3hWF4JCVbpAweyOKHEUA==" + "resolved": "4.7.2", + "contentHash": "TcMd95wcrubm9nHvJEQs70rC0H/8omiSGGpU4FQ/ZA1URIqD4pjmFJh2Mfv1yH1eHgJDWTi2hMDXwTET+zOOyg==" }, "System.Text.RegularExpressions": { "type": "Transitive", @@ -1689,40 +1721,40 @@ "corvus.identity.azure": { "type": "Project", "dependencies": { - "Azure.Identity": "1.5.0", - "Azure.Security.KeyVault.Secrets": "4.2.0", - "Corvus.Identity.Abstractions": "1.0.0", - "Microsoft.Extensions.Caching.Memory": "6.0.0", - "Microsoft.Extensions.DependencyInjection.Abstractions": "6.0.0" + "Azure.Identity": "[1.7.0-beta.1, )", + "Azure.Security.KeyVault.Secrets": "[4.2.0, )", + "Corvus.Identity.Abstractions": "[1.0.0, )", + "Microsoft.Extensions.Caching.Memory": "[6.0.*, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[6.0.*, )" } }, "corvus.identity.examples.usingazurecore": { "type": "Project", "dependencies": { - "Azure.Security.KeyVault.Secrets": "4.2.0", - "Corvus.Identity.Azure": "1.0.0" + "Azure.Security.KeyVault.Secrets": "[4.2.0, )", + "Corvus.Identity.Azure": "[1.0.0, )" } }, "corvus.identity.examples.usingmicrosoftrest": { "type": "Project", "dependencies": { - "Corvus.Identity.MicrosoftRest": "1.0.0", - "Microsoft.Azure.Management.Websites": "3.1.2" + "Corvus.Identity.MicrosoftRest": "[1.0.0, )", + "Microsoft.Azure.Management.Websites": "[3.1.2, )" } }, "corvus.identity.examples.usingplaintokens": { "type": "Project", "dependencies": { - "Corvus.Identity.Azure": "1.0.0", - "Microsoft.Extensions.Http": "6.0.0" + "Corvus.Identity.Azure": "[1.0.0, )", + "Microsoft.Extensions.Http": "[6.0.*, )" } }, "corvus.identity.microsoftrest": { "type": "Project", "dependencies": { - "Corvus.Identity.Azure": "1.0.0", - "Microsoft.Extensions.DependencyInjection.Abstractions": "6.0.0", - "Microsoft.Rest.ClientRuntime": "2.3.23" + "Corvus.Identity.Azure": "[1.0.0, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[6.0.*, )", + "Microsoft.Rest.ClientRuntime": "[2.3.23, )" } } } diff --git a/Solutions/Corvus.Identity.Specs/Corvus/Identity/Azure/AzureTokenCredentialAccessTokenSource.feature b/Solutions/Corvus.Identity.Specs/Corvus/Identity/Azure/AzureTokenCredentialAccessTokenSource.feature index 24dd08c..c3fd3a0 100644 --- a/Solutions/Corvus.Identity.Specs/Corvus/Identity/Azure/AzureTokenCredentialAccessTokenSource.feature +++ b/Solutions/Corvus.Identity.Specs/Corvus/Identity/Azure/AzureTokenCredentialAccessTokenSource.feature @@ -12,7 +12,7 @@ Scenario: Successful token acquisition with just scopes And the TenantId passed to TokenCredential.GetTokenAsync should be null And the ParentRequestId passed to TokenCredential.GetTokenAsync should be null And the AccessToken returned by IAccessTokenSource.GetAccessTokenAsync should be the same as was returned by TokenCredential.GetTokenAsync - And the ExpiresOn returned by IAccessTokenSource.GetAccessTokenAsync should be the same as was returned by TokenCredential.GetTokenAsync + And the ExpiresOn returned by IAccessTokenSource.GetAccessTokenAsync should about three minutes into the future Scenario: Successful token acquisition with scopes and claims Given the AccessTokenRequest scope is 'https://management.core.windows.net/.default' @@ -24,7 +24,7 @@ Scenario: Successful token acquisition with scopes and claims And the TenantId passed to TokenCredential.GetTokenAsync should be null And the ParentRequestId passed to TokenCredential.GetTokenAsync should be null And the AccessToken returned by IAccessTokenSource.GetAccessTokenAsync should be the same as was returned by TokenCredential.GetTokenAsync - And the ExpiresOn returned by IAccessTokenSource.GetAccessTokenAsync should be the same as was returned by TokenCredential.GetTokenAsync + And the ExpiresOn returned by IAccessTokenSource.GetAccessTokenAsync should about three minutes into the future Scenario: Successful token acquisition with scopes and authority id Given the AccessTokenRequest scope is 'https://management.core.windows.net/.default' @@ -36,7 +36,7 @@ Scenario: Successful token acquisition with scopes and authority id And the AuthorityId should have been passed on to TokenCredential.GetTokenAsync as the TenantId And the ParentRequestId passed to TokenCredential.GetTokenAsync should be null And the AccessToken returned by IAccessTokenSource.GetAccessTokenAsync should be the same as was returned by TokenCredential.GetTokenAsync - And the ExpiresOn returned by IAccessTokenSource.GetAccessTokenAsync should be the same as was returned by TokenCredential.GetTokenAsync + And the ExpiresOn returned by IAccessTokenSource.GetAccessTokenAsync should about three minutes into the future Scenario: Successful token acquisition with scopes, claims, and authority id Given the AccessTokenRequest scope is 'https://management.core.windows.net/.default' @@ -49,7 +49,7 @@ Scenario: Successful token acquisition with scopes, claims, and authority id And the AuthorityId should have been passed on to TokenCredential.GetTokenAsync as the TenantId And the ParentRequestId passed to TokenCredential.GetTokenAsync should be null And the AccessToken returned by IAccessTokenSource.GetAccessTokenAsync should be the same as was returned by TokenCredential.GetTokenAsync - And the ExpiresOn returned by IAccessTokenSource.GetAccessTokenAsync should be the same as was returned by TokenCredential.GetTokenAsync + And the ExpiresOn returned by IAccessTokenSource.GetAccessTokenAsync should about three minutes into the future # The various implementations of TokenCredential supplied by Azure.Identity throw either a @@ -87,7 +87,7 @@ Scenario: Replace token via IAccessTokenSource And the TenantId passed to TokenCredential.GetTokenAsync should be null And the ParentRequestId passed to TokenCredential.GetTokenAsync should be null And the AccessToken returned by IAccessTokenSource.GetAccessTokenAsync should be the same as was returned by TokenCredential.GetTokenAsync - And the ExpiresOn returned by IAccessTokenSource.GetAccessTokenAsync should be the same as was returned by TokenCredential.GetTokenAsync + And the ExpiresOn returned by IAccessTokenSource.GetAccessTokenAsync should about three minutes into the future Scenario: Replace token via IAccessTokenSourceFromDynamicConfiguration Given the AccessTokenRequest scope is 'https://management.core.windows.net/.default' @@ -101,4 +101,4 @@ Scenario: Replace token via IAccessTokenSourceFromDynamicConfiguration And the TenantId passed to TokenCredential.GetTokenAsync should be null And the ParentRequestId passed to TokenCredential.GetTokenAsync should be null And the AccessToken returned by IAccessTokenSource.GetAccessTokenAsync should be the same as was returned by TokenCredential.GetTokenAsync - And the ExpiresOn returned by IAccessTokenSource.GetAccessTokenAsync should be the same as was returned by TokenCredential.GetTokenAsync + And the ExpiresOn returned by IAccessTokenSource.GetAccessTokenAsync should about three minutes into the future diff --git a/Solutions/Corvus.Identity.Specs/Corvus/Identity/Azure/AzureTokenCredentialAccessTokenSourceSteps.cs b/Solutions/Corvus.Identity.Specs/Corvus/Identity/Azure/AzureTokenCredentialAccessTokenSourceSteps.cs index e0cc775..807fc9f 100644 --- a/Solutions/Corvus.Identity.Specs/Corvus/Identity/Azure/AzureTokenCredentialAccessTokenSourceSteps.cs +++ b/Solutions/Corvus.Identity.Specs/Corvus/Identity/Azure/AzureTokenCredentialAccessTokenSourceSteps.cs @@ -40,8 +40,6 @@ public AzureTokenCredentialAccessTokenSourceSteps() AzureTokenCredentialSource tokenCredentialSource = new ( new TestTokenCredential(this), _ => this.ReplacementTokenRequested()); - ////this.accessTokenSource = new AzureTokenCredentialAccessTokenSource( - //// tokenCredentialSource); this.sourceFromDynamicConfiguration = new AccessTokenSourceFromDynamicConfiguration( new TestTokenCredentialSourceFromConfig(this, tokenCredentialSource)); @@ -158,13 +156,20 @@ public void ThenTheParentRequestIdPassedToTokenCredential_GetTokenAsyncShouldBeN [Then(@"the AccessToken returned by IAccessTokenSource\.GetAccessTokenAsync should be the same as was returned by TokenCredential\.GetTokenAsync")] public async Task ThenTheAccessTokenReturnedByIAccessTokenSource_GetAccessTokenAsyncShouldBeTheSameAsWasReturnedByTokenCredential_GetTokenAsync() { - Assert.AreSame(this.resultFromUnderlyingCredential.Token, (await this.accessTokenDetailReturnedTask.ConfigureAwait(false)).AccessToken); + Assert.AreEqual(this.resultFromUnderlyingCredential.Token, (await this.accessTokenDetailReturnedTask.ConfigureAwait(false)).AccessToken); } - [Then(@"the ExpiresOn returned by IAccessTokenSource\.GetAccessTokenAsync should be the same as was returned by TokenCredential\.GetTokenAsync")] + [Then(@"the ExpiresOn returned by IAccessTokenSource\.GetAccessTokenAsync should about three minutes into the future")] public async Task ThenTheExpiresOnReturnedByIAccessTokenSource_GetAccessTokenAsyncShouldBeTheSameAsWasReturnedByTokenCredential_GetTokenAsync() { - Assert.AreEqual(this.resultFromUnderlyingCredential.ExpiresOn, (await this.accessTokenDetailReturnedTask.ConfigureAwait(false)).ExpiresOn); + // Because of the limited access to the Azure.Core token caching functionality, it's + // not possible for us to determine the real token expiration time. (We'd have to + // write our own cache logic to do that.) We know that the token cache we are + // (indirectly) using refreshes tokens 5 minutes before they expire, so we just + // report a fixed expiration time a little into the future. + DateTimeOffset reportedExpiration = (await this.accessTokenDetailReturnedTask.ConfigureAwait(false)).ExpiresOn; + TimeSpan diff = DateTimeOffset.UtcNow.AddMinutes(3) - reportedExpiration; + Assert.IsTrue(Math.Abs(diff.TotalSeconds) < 30); } [Then(@"the Claims should have been passed on to TokenCredential\.GetTokenAsync")] diff --git a/Solutions/Corvus.Identity.Specs/Corvus/Identity/Azure/TokenCredentialSourceFromDynamicConfiguration/TokenCredentialSourceFromDynamicConfigurationSteps.cs b/Solutions/Corvus.Identity.Specs/Corvus/Identity/Azure/TokenCredentialSourceFromDynamicConfiguration/TokenCredentialSourceFromDynamicConfigurationSteps.cs index 817b6e0..4984451 100644 --- a/Solutions/Corvus.Identity.Specs/Corvus/Identity/Azure/TokenCredentialSourceFromDynamicConfiguration/TokenCredentialSourceFromDynamicConfigurationSteps.cs +++ b/Solutions/Corvus.Identity.Specs/Corvus/Identity/Azure/TokenCredentialSourceFromDynamicConfiguration/TokenCredentialSourceFromDynamicConfigurationSteps.cs @@ -219,7 +219,6 @@ private void CheckCacheOperations(Table table, List actualRows, break; } } - } public class TestConfiguration diff --git a/Solutions/Corvus.Identity.Specs/packages.lock.json b/Solutions/Corvus.Identity.Specs/packages.lock.json index b74a0da..37392b9 100644 --- a/Solutions/Corvus.Identity.Specs/packages.lock.json +++ b/Solutions/Corvus.Identity.Specs/packages.lock.json @@ -79,29 +79,29 @@ }, "Azure.Core": { "type": "Transitive", - "resolved": "1.20.0", - "contentHash": "q7xigZIBjLjSKJA/Y+VygmJ2iZGiEyNuicN5iRX9oJL7451SulZm/CQ7qd8YCeL5TgNCNYCIrTIqRaams95zHA==", + "resolved": "1.25.0", + "contentHash": "X8Dd4sAggS84KScWIjEbFAdt2U1KDolQopTPoHVubG2y3CM54f9l6asVrP5Uy384NWXjsspPYaJgz5xHc+KvTA==", "dependencies": { - "Microsoft.Bcl.AsyncInterfaces": "1.0.0", + "Microsoft.Bcl.AsyncInterfaces": "1.1.1", "System.Diagnostics.DiagnosticSource": "4.6.0", "System.Memory.Data": "1.0.2", "System.Numerics.Vectors": "4.5.0", "System.Text.Encodings.Web": "4.7.2", - "System.Text.Json": "4.6.0", + "System.Text.Json": "4.7.2", "System.Threading.Tasks.Extensions": "4.5.4" } }, "Azure.Identity": { "type": "Transitive", - "resolved": "1.5.0", - "contentHash": "VfF88dqrgKXZNOS/y4XrX/jmIfP3pkY+hBUzBNpoKml1nR+QshX6XlXWyToLtWV80TDQ1CmUVCJksktDg5+j1w==", + "resolved": "1.7.0-beta.1", + "contentHash": "VelmyeHyLRcZGMQHFGYNXAGmSWnAmIQ/iB3kk2ERIdAM4JP+H0gmOct2Sy4RHeTHhBsulONMGO/ZsdrLTaOVgQ==", "dependencies": { - "Azure.Core": "1.20.0", - "Microsoft.Identity.Client": "4.30.1", - "Microsoft.Identity.Client.Extensions.Msal": "2.18.4", + "Azure.Core": "1.25.0", + "Microsoft.Identity.Client": "4.46.0", + "Microsoft.Identity.Client.Extensions.Msal": "2.23.0", "System.Memory": "4.5.4", - "System.Security.Cryptography.ProtectedData": "4.5.0", - "System.Text.Json": "4.6.0", + "System.Security.Cryptography.ProtectedData": "4.7.0", + "System.Text.Json": "4.7.2", "System.Threading.Tasks.Extensions": "4.5.4" } }, @@ -163,8 +163,8 @@ }, "Microsoft.Bcl.AsyncInterfaces": { "type": "Transitive", - "resolved": "1.0.0", - "contentHash": "K63Y4hORbBcKLWH5wnKgzyn7TOfYzevIEwIedQHBIkmkEBA9SCqgvom+XTuE+fAFGvINGkhFItaZ2dvMGdT5iw==" + "resolved": "1.1.1", + "contentHash": "yuvf07qFWFqtK3P/MRkEKLhn5r2UbSpVueRziSqj0yJQIKFwG1pq9mOayK3zE5qZCTs0CbrwL9M6R8VwqyGy2w==" }, "Microsoft.Build.Tasks.Git": { "type": "Transitive", @@ -239,8 +239,8 @@ }, "Microsoft.Extensions.Caching.Memory": { "type": "Transitive", - "resolved": "6.0.0", - "contentHash": "Ve3BlCzhAlVp5IgO3+8dacAhZk1A0GlIlFNkAcfR2TfAibLKWIt5DhVJZfu4YtW+XZ89OjYf/agMcgjDtPxdGA==", + "resolved": "6.0.1", + "contentHash": "B4y+Cev05eMcjf1na0v9gza6GUtahXbtY1JCypIgx3B4Ea/KAgsWyXEmW4q6zMbmTMtKzmPVk09rvFJirvMwTg==", "dependencies": { "Microsoft.Extensions.Caching.Abstractions": "6.0.0", "Microsoft.Extensions.DependencyInjection.Abstractions": "6.0.0", @@ -342,18 +342,27 @@ }, "Microsoft.Identity.Client": { "type": "Transitive", - "resolved": "4.30.1", - "contentHash": "xk8tJeGfB2yD3+d7a0DXyV7/HYyEG10IofUHYHoPYKmDbroi/j9t1BqSHgbq1nARDjg7m8Ki6e21AyNU7e/R4Q==" + "resolved": "4.46.0", + "contentHash": "cqNAIELaUypwWvTwnC3MdsccaSpEpVR10WBQ7+e33iSkceeC1kum6aTEwe2m8z4ZdnzlvEMH+dBbXBHMLCy+fQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "6.18.0" + } }, "Microsoft.Identity.Client.Extensions.Msal": { "type": "Transitive", - "resolved": "2.18.4", - "contentHash": "HpG4oLwhQsy0ce7OWq9iDdLtJKOvKRStIKoSEOeBMKuohfuOWNDyhg8fMAJkpG/kFeoe4J329fiMHcJmmB+FPw==", + "resolved": "2.23.0", + "contentHash": "Q8K58FjUIVslHQlk+SIhFYjdy8B1A5Wt3GXxzLS7lnXXaSmbcGzk7d8haqLmR8z/DP99vpZC73SxMa83qSHcbQ==", "dependencies": { - "Microsoft.Identity.Client": "4.30.0", + "Microsoft.Identity.Client": "4.46.0", + "System.IO.FileSystem.AccessControl": "5.0.0", "System.Security.Cryptography.ProtectedData": "4.5.0" } }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "6.18.0", + "contentHash": "ItCO09JoIQr9sY0AumHRLJKToMKM4/jFcBsg3uhKBZZLX1KPxjed/mKrQzo9PXiarfC87rguvFWWg9C996sEqA==" + }, "Microsoft.NET.Test.Sdk": { "type": "Transitive", "resolved": "16.11.0", @@ -365,8 +374,8 @@ }, "Microsoft.NETCore.Platforms": { "type": "Transitive", - "resolved": "3.1.0", - "contentHash": "z7aeg8oHln2CuNulfhiLYxCVMPEwBl3rzicjvIX+4sUuCwvXw5oXQEtbiU2c0z4qYL5L3Kmx0mMA/+t/SbY67w==" + "resolved": "5.0.0", + "contentHash": "VyPlqzH2wavqquTcYpkIIAQ6WdenuKoFN0BdYBbCWsclXacSOHNQn66Gt4z5NBqEYW0FAPm5rlvki9ZiCij5xQ==" }, "Microsoft.NETCore.Targets": { "type": "Transitive", @@ -957,6 +966,15 @@ "System.Threading.Tasks": "4.3.0" } }, + "System.IO.FileSystem.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "SxHB3nuNrpptVk+vZ/F+7OHEpoHUIKKMl02bUmYHQr1r+glbZQxs7pRtsf4ENO29TVm2TH3AEeep2fJcy92oYw==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, "System.IO.FileSystem.Primitives": { "type": "Transitive", "resolved": "4.3.0", @@ -1283,11 +1301,11 @@ }, "System.Security.AccessControl": { "type": "Transitive", - "resolved": "4.7.0", - "contentHash": "JECvTt5aFF3WT3gHpfofL2MNNP6v84sxtXxpqhLBCcDRzqsPBmHhQ6shv4DwwN2tRlzsUxtb3G9M3763rbXKDg==", + "resolved": "5.0.0", + "contentHash": "dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==", "dependencies": { - "Microsoft.NETCore.Platforms": "3.1.0", - "System.Security.Principal.Windows": "4.7.0" + "Microsoft.NETCore.Platforms": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" } }, "System.Security.Cryptography.Algorithms": { @@ -1404,8 +1422,8 @@ }, "System.Security.Cryptography.ProtectedData": { "type": "Transitive", - "resolved": "4.5.0", - "contentHash": "wLBKzFnDCxP12VL9ANydSYhk59fC4cvOr9ypYQLPnAj48NQIhqnjdD2yhP8yEKyBJEjERWS9DisKL7rX5eU25Q==" + "resolved": "4.7.0", + "contentHash": "ehYW0m9ptxpGWvE4zgqongBVWpSDU/JCFD4K7krxkQwSz/sFQjEXCUqpvencjy6DYDbn7Ig09R8GFffu8TtneQ==" }, "System.Security.Cryptography.X509Certificates": { "type": "Transitive", @@ -1449,8 +1467,8 @@ }, "System.Security.Principal.Windows": { "type": "Transitive", - "resolved": "4.7.0", - "contentHash": "ojD0PX0XhneCsUbAZVKdb7h/70vyYMDYs85lwEI+LngEONe/17A0cFaRFqZU+sOEidcVswYWikYOQ9PPfjlbtQ==" + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" }, "System.Text.Encoding": { "type": "Transitive", @@ -1617,19 +1635,19 @@ "corvus.identity.azure": { "type": "Project", "dependencies": { - "Azure.Identity": "1.5.0", - "Azure.Security.KeyVault.Secrets": "4.2.0", - "Corvus.Identity.Abstractions": "1.0.0", - "Microsoft.Extensions.Caching.Memory": "6.0.0", - "Microsoft.Extensions.DependencyInjection.Abstractions": "6.0.0" + "Azure.Identity": "[1.7.0-beta.1, )", + "Azure.Security.KeyVault.Secrets": "[4.2.0, )", + "Corvus.Identity.Abstractions": "[1.0.0, )", + "Microsoft.Extensions.Caching.Memory": "[6.0.*, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[6.0.*, )" } }, "corvus.identity.microsoftrest": { "type": "Project", "dependencies": { - "Corvus.Identity.Azure": "1.0.0", - "Microsoft.Extensions.DependencyInjection.Abstractions": "6.0.0", - "Microsoft.Rest.ClientRuntime": "2.3.23" + "Corvus.Identity.Azure": "[1.0.0, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[6.0.*, )", + "Microsoft.Rest.ClientRuntime": "[2.3.23, )" } } }