Skip to content

Commit

Permalink
azrepos: add Azure authority cache
Browse files Browse the repository at this point in the history
Add a cache of the Azure backing authority for Azure DevOps orgs.
This cache is only consulted when the credential type is "oauth"
and not "pat".

We use Git's configuration as the persistence mechanism.
  • Loading branch information
mjcheetham committed Mar 9, 2021
1 parent 7e493d7 commit 8e351df
Show file tree
Hide file tree
Showing 5 changed files with 376 additions and 14 deletions.
189 changes: 189 additions & 0 deletions src/shared/Microsoft.AzureRepos.Tests/AzureReposAuthorityCacheTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license.
using System;
using System.Collections.Generic;
using System.Globalization;
using Microsoft.Git.CredentialManager;
using Microsoft.Git.CredentialManager.Tests.Objects;
using Xunit;

namespace Microsoft.AzureRepos.Tests
{
public class AzureReposAuthorityCacheTests
{
[Fact]
public void AzureReposAuthorityCache_GetAuthority_Null_ThrowException()
{
var trace = new NullTrace();
var git = new TestGit();
var cache = new AzureDevOpsAuthorityCache(trace, git);

Assert.Throws<ArgumentNullException>(() => cache.GetAuthority(null));
}

[Fact]
public void AzureReposAuthorityCache_GetAuthority_NoCachedAuthority_ReturnsNull()
{
string key = CreateKey("contoso");

var trace = new NullTrace();
var git = new TestGit();
var cache = new AzureDevOpsAuthorityCache(trace, git);

string authority = cache.GetAuthority(key);

Assert.Null(authority);
}

[Fact]
public void AzureReposAuthorityCache_GetAuthority_CachedAuthority_ReturnsAuthority()
{
const string orgName = "contoso";
string key = CreateKey(orgName);
const string expectedAuthority = "https://login.contoso.com";

var git = new TestGit
{
Configuration =
{
Global =
{
[key] = new[] {expectedAuthority}
}
}
};

var trace = new NullTrace();
var cache = new AzureDevOpsAuthorityCache(trace, git);

string actualAuthority = cache.GetAuthority(orgName);

Assert.Equal(expectedAuthority, actualAuthority);
}

[Fact]
public void AzureReposAuthorityCache_UpdateAuthority_NoCachedAuthority_SetsAuthority()
{
const string orgName = "contoso";
string key = CreateKey(orgName);
const string expectedAuthority = "https://login.contoso.com";

var trace = new NullTrace();
var git = new TestGit();
var cache = new AzureDevOpsAuthorityCache(trace, git);

cache.UpdateAuthority(orgName, expectedAuthority);

Assert.True(git.Configuration.Global.TryGetValue(key, out IList<string> values));
Assert.Single(values);
string actualAuthority = values[0];
Assert.Equal(expectedAuthority, actualAuthority);
}

[Fact]
public void AzureReposAuthorityCache_UpdateAuthority_CachedAuthority_UpdatesAuthority()
{
const string orgName = "contoso";
string key = CreateKey(orgName);
const string oldAuthority = "https://old-login.contoso.com";
const string expectedAuthority = "https://login.contoso.com";

var git = new TestGit
{
Configuration =
{
Global =
{
[key] = new[] {oldAuthority}
}
}
};

var trace = new NullTrace();
var cache = new AzureDevOpsAuthorityCache(trace, git);

cache.UpdateAuthority(orgName, expectedAuthority);

Assert.True(git.Configuration.Global.TryGetValue(key, out IList<string> values));
Assert.Single(values);
string actualAuthority = values[0];
Assert.Equal(expectedAuthority, actualAuthority);
}

[Fact]
public void AzureReposAuthorityCache_EraseAuthority_NoCachedAuthority_DoesNothing()
{
const string orgName = "contoso";
string key = CreateKey(orgName);
string otherKey = CreateKey("org.fabrikam.authority");
const string otherAuthority = "https://fabrikam.com/login";

var git = new TestGit
{
Configuration =
{
Global =
{
[otherKey] = new[] {otherAuthority}
}
}
};

var trace = new NullTrace();
var cache = new AzureDevOpsAuthorityCache(trace, git);

cache.EraseAuthority(orgName);

// Other entries should remain
Assert.False(git.Configuration.Global.ContainsKey(key));
Assert.Single(git.Configuration.Global);
Assert.True(git.Configuration.Global.TryGetValue(otherKey, out IList<string> values));
Assert.Single(values);
string actualOtherAuthority = values[0];
Assert.Equal(otherAuthority, actualOtherAuthority);
}

[Fact]
public void AzureReposAuthorityCache_EraseAuthority_CachedAuthority_RemovesAuthority()
{
const string orgName = "contoso";
string key = CreateKey(orgName);
const string authority = "https://login.contoso.com";
string otherKey = CreateKey("fabrikam");
const string otherAuthority = "https://fabrikam.com/login";

var git = new TestGit
{
Configuration =
{
Global =
{
[key] = new[] {authority},
[otherKey] = new[] {otherAuthority}
}
}
};

var trace = new NullTrace();
var cache = new AzureDevOpsAuthorityCache(trace, git);

cache.EraseAuthority(orgName);

// Only the other entries should remain
Assert.False(git.Configuration.Global.ContainsKey(key));
Assert.Single(git.Configuration.Global);
Assert.True(git.Configuration.Global.TryGetValue(otherKey, out IList<string> values));
Assert.Single(values);
string actualOtherAuthority = values[0];
Assert.Equal(otherAuthority, actualOtherAuthority);
}

private static string CreateKey(string orgName)
{
return string.Format(CultureInfo.InvariantCulture, "{0}.{1}:{2}/{3}.{4}",
Constants.GitConfiguration.Credential.SectionName,
AzureDevOpsConstants.UrnScheme, AzureDevOpsConstants.UrnOrgPrefix, orgName,
AzureDevOpsConstants.GitConfiguration.Credential.AzureAuthority);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -132,15 +132,17 @@ public async Task AzureReposProvider_GetCredentialAsync_UnencryptedHttp_ThrowsEx
var context = new TestCommandContext();
var azDevOps = Mock.Of<IAzureDevOpsRestApi>();
var msAuth = Mock.Of<IMicrosoftAuthentication>();
var authorityCache = Mock.Of<IAzureDevOpsAuthorityCache>();

var provider = new AzureReposHostProvider(context, azDevOps, msAuth);
var provider = new AzureReposHostProvider(context, azDevOps, msAuth, authorityCache);

await Assert.ThrowsAsync<Exception>(() => provider.GetCredentialAsync(input));
}

[Fact]
public async Task AzureReposProvider_GetCredentialAsync_JwtMode_VsComUrlUser_ReturnsCredential()
public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_VsComUrlUser_ReturnsCredential()
{
var orgName = "org";
var urlAccount = "jane.doe";

var input = new InputArguments(new Dictionary<string, string>
Expand Down Expand Up @@ -172,7 +174,10 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_VsComUrlUser_Ret
msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, urlAccount))
.ReturnsAsync(authResult);

var provider = new AzureReposHostProvider(context, azDevOpsMock.Object, msAuthMock.Object);
var authorityCacheMock = new Mock<IAzureDevOpsAuthorityCache>(MockBehavior.Strict);
authorityCacheMock.Setup(x => x.GetAuthority(orgName)).Returns(authorityUrl);

var provider = new AzureReposHostProvider(context, azDevOpsMock.Object, msAuthMock.Object, authorityCacheMock.Object);

ICredential credential = await provider.GetCredentialAsync(input);

Expand All @@ -182,8 +187,9 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_VsComUrlUser_Ret
}

[Fact]
public async Task AzureReposProvider_GetCredentialAsync_JwtMode_DevAzureUrlUser_ReturnsCredential()
public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_DevAzureUrlUser_ReturnsCredential()
{
var orgName = "org";
var urlAccount = "jane.doe";

var input = new InputArguments(new Dictionary<string, string>
Expand Down Expand Up @@ -216,7 +222,10 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_DevAzureUrlUser_
msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, urlAccount))
.ReturnsAsync(authResult);

var provider = new AzureReposHostProvider(context, azDevOpsMock.Object, msAuthMock.Object);
var authorityCacheMock = new Mock<IAzureDevOpsAuthorityCache>(MockBehavior.Strict);
authorityCacheMock.Setup(x => x.GetAuthority(orgName)).Returns(authorityUrl);

var provider = new AzureReposHostProvider(context, azDevOpsMock.Object, msAuthMock.Object, authorityCacheMock.Object);

ICredential credential = await provider.GetCredentialAsync(input);

Expand All @@ -226,8 +235,10 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_DevAzureUrlUser_
}

[Fact]
public async Task AzureReposProvider_GetCredentialAsync_JwtMode_DevAzureUrlOrgName_ReturnsCredential()
public async Task AzureReposProvider_GetCredentialAsync_JwtMode_CachedAuthority_DevAzureUrlOrgName_ReturnsCredential()
{
var orgName = "org";

var input = new InputArguments(new Dictionary<string, string>
{
["protocol"] = "https",
Expand Down Expand Up @@ -259,7 +270,10 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_DevAzureUrlOrgNa
msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, null))
.ReturnsAsync(authResult);

var provider = new AzureReposHostProvider(context, azDevOpsMock.Object, msAuthMock.Object);
var authorityCacheMock = new Mock<IAzureDevOpsAuthorityCache>(MockBehavior.Strict);
authorityCacheMock.Setup(x => x.GetAuthority(orgName)).Returns(authorityUrl);

var provider = new AzureReposHostProvider(context, azDevOpsMock.Object, msAuthMock.Object, authorityCacheMock.Object);

ICredential credential = await provider.GetCredentialAsync(input);

Expand All @@ -269,8 +283,10 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_DevAzureUrlOrgNa
}

[Fact]
public async Task AzureReposProvider_GetCredentialAsync_JwtMode_NoUser_ReturnsCredential()
public async Task AzureReposProvider_GetCredentialAsync_JwtMode_NoCachedAuthority_ReturnsCredential()
{
var orgName = "org";

var input = new InputArguments(new Dictionary<string, string>
{
["protocol"] = "https",
Expand Down Expand Up @@ -301,7 +317,11 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_NoUser_ReturnsCr
msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, null))
.ReturnsAsync(authResult);

var provider = new AzureReposHostProvider(context, azDevOpsMock.Object, msAuthMock.Object);
var authorityCacheMock = new Mock<IAzureDevOpsAuthorityCache>(MockBehavior.Strict);
authorityCacheMock.Setup(x => x.GetAuthority(It.IsAny<string>())).Returns((string)null);
authorityCacheMock.Setup(x => x.UpdateAuthority(orgName, authorityUrl));

var provider = new AzureReposHostProvider(context, azDevOpsMock.Object, msAuthMock.Object, authorityCacheMock.Object);

ICredential credential = await provider.GetCredentialAsync(input);

Expand All @@ -313,6 +333,8 @@ public async Task AzureReposProvider_GetCredentialAsync_JwtMode_NoUser_ReturnsCr
[Fact]
public async Task AzureReposProvider_GetCredentialAsync_PatMode_NoExistingPat_GeneratesCredential()
{
var orgName = "org";

var input = new InputArguments(new Dictionary<string, string>
{
["protocol"] = "https",
Expand Down Expand Up @@ -342,7 +364,9 @@ public async Task AzureReposProvider_GetCredentialAsync_PatMode_NoExistingPat_Ge
msAuthMock.Setup(x => x.GetTokenAsync(authorityUrl, expectedClientId, expectedRedirectUri, expectedScopes, null))
.ReturnsAsync(authResult);

var provider = new AzureReposHostProvider(context, azDevOpsMock.Object, msAuthMock.Object);
var authorityCacheMock = new Mock<IAzureDevOpsAuthorityCache>(MockBehavior.Strict);

var provider = new AzureReposHostProvider(context, azDevOpsMock.Object, msAuthMock.Object, authorityCacheMock.Object);

ICredential credential = await provider.GetCredentialAsync(input);

Expand Down Expand Up @@ -372,8 +396,9 @@ public async Task AzureReposProvider_GetCredentialAsync_PatMode_ExistingPat_Retu

var azDevOps = Mock.Of<IAzureDevOpsRestApi>();
var msAuth = Mock.Of<IMicrosoftAuthentication>();
var authorityCache = Mock.Of<IAzureDevOpsAuthorityCache>();

var provider = new AzureReposHostProvider(context, azDevOps, msAuth);
var provider = new AzureReposHostProvider(context, azDevOps, msAuth, authorityCache);

ICredential credential = await provider.GetCredentialAsync(input);

Expand Down
Loading

0 comments on commit 8e351df

Please sign in to comment.