Skip to content

Commit 03f08dd

Browse files
author
maddieclayton
authored
Merge pull request #5227 from markcowl/msilogin
Managed Service Identity Login
2 parents 75ab8ef + bfcefb5 commit 03f08dd

File tree

25 files changed

+764
-33
lines changed

25 files changed

+764
-33
lines changed

src/Common/Commands.Common.Authentication.Abstractions/AzureAccount.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@ public static class AccountType
8484
public const string Certificate = "Certificate",
8585
User = "User",
8686
ServicePrincipal = "ServicePrincipal",
87-
AccessToken = "AccessToken";
87+
AccessToken = "AccessToken",
88+
ManagedService = "ManagedService";
8889
}
8990

9091
/// <summary>
@@ -117,7 +118,12 @@ public static class Property
117118
/// <summary>
118119
/// Thumbprint for associated certificate
119120
/// </summary>
120-
CertificateThumbprint = "CertificateThumbprint";
121+
CertificateThumbprint = "CertificateThumbprint",
122+
123+
/// <summary>
124+
/// Login Uri for Managed Service Login
125+
/// </summary>
126+
MSILoginUri = "MSILoginUri";
121127

122128

123129
}

src/Common/Commands.Common.Authentication.Abstractions/Interfaces/IExtensibleSettings.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
using System;
2-
// ----------------------------------------------------------------------------------
1+
// ----------------------------------------------------------------------------------
32
//
43
// Copyright Microsoft Corporation
54
// Licensed under the Apache License, Version 2.0 (the "License");

src/Common/Commands.Common.Authentication.Abstractions/Properties/AssemblyInfo.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,4 @@
4747
// by using the '*' as shown below:
4848
// [assembly: AssemblyVersion("1.0.*")]
4949
[assembly: AssemblyVersion("1.0.0.0")]
50-
[assembly: AssemblyFileVersion("1.0.0.0")]
50+
[assembly: AssemblyFileVersion("1.1.0.0")]

src/Common/Commands.Common.Authentication.Test/AuthenticationFactoryTests.cs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,20 @@
2222
using Xunit;
2323
using Microsoft.Azure.Commands.Common.Authentication.Abstractions;
2424
using System.Linq;
25+
using Microsoft.Azure.Commands.Common.Authentication.Test;
26+
using Microsoft.WindowsAzure.Commands.Utilities.Common;
27+
using Xunit.Abstractions;
2528

2629
namespace Common.Authentication.Test
2730
{
2831
public class AuthenticationFactoryTests
2932
{
33+
ITestOutputHelper _output;
34+
public AuthenticationFactoryTests(ITestOutputHelper output)
35+
{
36+
_output = output;
37+
}
38+
3039
[Fact]
3140
[Trait(Category.AcceptanceType, Category.CheckIn)]
3241
public void VerifySubscriptionTokenCacheRemove()
@@ -135,6 +144,59 @@ public void CanAuthenticateWithAccessToken()
135144
VerifyToken(checkKVToken, kvToken, userId, tenant);
136145
}
137146

147+
[Fact]
148+
[Trait(Category.AcceptanceType, Category.CheckIn)]
149+
public void CanAuthenticateUsingMSIDefault()
150+
{
151+
AzureSessionInitializer.InitializeAzureSession();
152+
string expectedAccessToken = Guid.NewGuid().ToString();
153+
_output.WriteLine("Expected access token for default URI: {0}", expectedAccessToken);
154+
string expectedToken2 = Guid.NewGuid().ToString();
155+
string tenant = Guid.NewGuid().ToString();
156+
_output.WriteLine("Expected access token for custom URI: {0}", expectedToken2);
157+
string userId = "[email protected]";
158+
var account = new AzureAccount
159+
{
160+
Id = userId,
161+
Type = AzureAccount.AccountType.ManagedService
162+
};
163+
var environment = AzureEnvironment.PublicEnvironments["AzureCloud"];
164+
var expectedResource = environment.ActiveDirectoryServiceEndpointResourceId;
165+
var builder = new UriBuilder(AuthenticationFactory.DefaultMSILoginUri);
166+
builder.Query = string.Format("resource={0}", Uri.EscapeDataString(environment.ActiveDirectoryServiceEndpointResourceId));
167+
var defaultUri = builder.Uri.ToString();
168+
169+
var responses = new Dictionary<string, ManagedServiceTokenInfo>(StringComparer.OrdinalIgnoreCase)
170+
{
171+
{defaultUri, new ManagedServiceTokenInfo { AccessToken = expectedAccessToken, ExpiresIn = 3600, Resource=expectedResource}},
172+
{"http://myfunkyurl:10432/oauth2/token?resource=foo", new ManagedServiceTokenInfo { AccessToken = expectedToken2, ExpiresIn = 3600, Resource="foo"} }
173+
};
174+
AzureSession.Instance.RegisterComponent(HttpClientOperationsFactory.Name, () => TestHttpOperationsFactory.Create(responses, _output), true);
175+
var authFactory = new AuthenticationFactory();
176+
var token = authFactory.Authenticate(account, environment, tenant, null, null, null);
177+
_output.WriteLine($"Received access token for default Uri ${token.AccessToken}");
178+
Assert.Equal(expectedAccessToken, token.AccessToken);
179+
var account2 = new AzureAccount
180+
{
181+
Id = userId,
182+
Type = AzureAccount.AccountType.ManagedService
183+
};
184+
account2.SetProperty(AzureAccount.Property.MSILoginUri, "http://myfunkyurl:10432/oauth2/token");
185+
var token2 = authFactory.Authenticate(account2, environment, tenant, null, null, null, "foo");
186+
_output.WriteLine($"Received access token for custom Uri ${token2.AccessToken}");
187+
Assert.Equal(expectedToken2, token2.AccessToken);
188+
var token3 = authFactory.Authenticate(account, environment, tenant, null, null, null, "bar");
189+
Assert.Throws<InvalidOperationException>(() => token3.AccessToken);
190+
}
191+
192+
[Fact]
193+
[Trait(Category.AcceptanceType, Category.CheckIn)]
194+
void ResponseRedactionWorks()
195+
{
196+
Assert.Equal(" \"access_token\": \"<redacted>\"", GeneralUtilities.TransformBody(" \"access_token\": \"eyJo1234567\""));
197+
Assert.Equal(" \"foo\": \"bar\"", GeneralUtilities.TransformBody(" \"foo\": \"bar\""));
198+
}
199+
138200
void VerifyToken(IAccessToken checkToken, string expectedAccessToken, string expectedUserId, string expectedTenant)
139201
{
140202

src/Common/Commands.Common.Authentication.Test/Commands.Common.Authentication.Test.csproj

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<?xml version="1.0" encoding="utf-8"?>
22
<Project ToolsVersion="12.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
33
<Import Project="..\..\packages\xunit.runner.visualstudio.2.1.0\build\net20\xunit.runner.visualstudio.props" Condition="Exists('..\..\packages\xunit.runner.visualstudio.2.1.0\build\net20\xunit.runner.visualstudio.props')" />
4-
<Import Project="..\..\packages\xunit.core.2.1.0\build\portable-net45+win8+wp8+wpa81\xunit.core.props" Condition="Exists('..\..\packages\xunit.core.2.1.0\build\portable-net45+win8+wp8+wpa81\xunit.core.props')" />
4+
<Import Project="..\..\packages\xunit.core.2.1.0\build\portable-net45+win8+wp8+wpa81\xunit.core.props" Condition="Exists('..\..\packages\xunit.core.2.1.0\build\portable-net45+win8+wp8+wpa81\xunit.core.props')"/>
55
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
66
<PropertyGroup>
77
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
@@ -132,6 +132,7 @@
132132
<DesignTime>True</DesignTime>
133133
<DependentUpon>Resources.resx</DependentUpon>
134134
</Compile>
135+
<Compile Include="TestHttpOperationsFactory.cs" />
135136
<EmbeddedResource Include="Resources\ResourceLocator.cs" />
136137
</ItemGroup>
137138
<ItemGroup>
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// ----------------------------------------------------------------------------------
2+
//
3+
// Copyright Microsoft Corporation
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
// ----------------------------------------------------------------------------------
14+
15+
using System;
16+
using System.Collections.Concurrent;
17+
using System.Collections.Generic;
18+
using System.Linq;
19+
using System.Text;
20+
using System.Threading;
21+
using System.Threading.Tasks;
22+
using Xunit.Abstractions;
23+
24+
namespace Microsoft.Azure.Commands.Common.Authentication.Test
25+
{
26+
public class TestHttpOperationsFactory : IHttpOperationsFactory
27+
{
28+
ITestOutputHelper _output;
29+
private TestHttpOperationsFactory()
30+
{
31+
}
32+
33+
IDictionary<string, object> _responses = new ConcurrentDictionary<string, object>(StringComparer.OrdinalIgnoreCase);
34+
public IHttpOperations<T> GetHttpOperations<T>()
35+
{
36+
var result = new TestHttpOperations<T>(_responses, _output);
37+
return result;
38+
}
39+
40+
public static IHttpOperationsFactory Create<T>(IDictionary<string, T> responses, ITestOutputHelper output)
41+
{
42+
var factory = new TestHttpOperationsFactory();
43+
factory._output = output;
44+
foreach (var response in responses)
45+
{
46+
output.WriteLine($"[TestHttpOperationsFactory]: Adding request response pair {response.Key} => {response.Value}");
47+
factory._responses.Add(response.Key, response.Value);
48+
}
49+
50+
return factory;
51+
}
52+
53+
class TestHttpOperations<T> : IHttpOperations<T>
54+
{
55+
ITestOutputHelper _output;
56+
IDictionary<string, T> _responses = new ConcurrentDictionary<string, T>(StringComparer.OrdinalIgnoreCase);
57+
58+
public TestHttpOperations(IDictionary<string, T> responses, ITestOutputHelper output)
59+
{
60+
_output = output;
61+
foreach (var responsePair in responses)
62+
{
63+
output.WriteLine($"[TestHttpOperations]: Adding request response pair {responsePair.Key} => {responsePair.Value}");
64+
_responses.Add(responsePair);
65+
}
66+
}
67+
public TestHttpOperations(IDictionary<string, object> responses, ITestOutputHelper output)
68+
{
69+
_output = output;
70+
foreach (var responsePair in responses)
71+
{
72+
output.WriteLine($"[TestHttpOperations]: Adding request response pair {responsePair.Key} => {responsePair.Value}");
73+
_responses.Add(responsePair.Key, (T)(responsePair.Value));
74+
}
75+
}
76+
77+
public Task DeleteAsync(string requestUri, CancellationToken token)
78+
{
79+
throw new NotImplementedException();
80+
}
81+
82+
public Task<T> GetAsync(string requestUri, CancellationToken token)
83+
{
84+
if (!_responses.ContainsKey(requestUri))
85+
{
86+
throw new InvalidOperationException(string.Format("Unexpected request Uri '{0}'", requestUri));
87+
}
88+
89+
var output = _responses[requestUri];
90+
91+
_output.WriteLine($"[TestHttpOperations]: Sent Response ({output}) to request GET {requestUri}");
92+
return Task.FromResult(output);
93+
}
94+
95+
public Task<bool> HeadAsync(string requestUri, CancellationToken token)
96+
{
97+
throw new NotImplementedException();
98+
}
99+
100+
public Task<IEnumerable<T>> ListAsync(string requestUri, CancellationToken token)
101+
{
102+
throw new NotImplementedException();
103+
}
104+
105+
public Task<T> PutAsync(string requestUri, T payload, CancellationToken token)
106+
{
107+
throw new NotImplementedException();
108+
}
109+
110+
public IHttpOperations<T> WithHeader(string name, IEnumerable<string> value)
111+
{
112+
// Allow the client to set one or more headers, but we are not using these in validation
113+
return this;
114+
}
115+
}
116+
}
117+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// ----------------------------------------------------------------------------------
2+
//
3+
// Copyright Microsoft Corporation
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
// ----------------------------------------------------------------------------------
14+
15+
using Microsoft.Azure.Commands.Common.Authentication.Abstractions;
16+
using Microsoft.Azure.Commands.Common.Authentication.Properties;
17+
using System;
18+
using System.Net.Http;
19+
using System.Threading;
20+
21+
namespace Microsoft.Azure.Commands.Common.Authentication
22+
{
23+
public class ManagedServiceAccessToken : IAccessToken
24+
{
25+
IAzureAccount _account;
26+
string _tenant;
27+
string _resourceId;
28+
IHttpOperations<ManagedServiceTokenInfo> _tokenGetter;
29+
DateTime _expiration = DateTime.UtcNow;
30+
string _accessToken;
31+
string _requestUri;
32+
33+
public ManagedServiceAccessToken(IAzureAccount account, IAzureEnvironment environment, string resourceId, string tenant = "Common")
34+
{
35+
if (account == null || string.IsNullOrEmpty(account.Id) || !account.IsPropertySet(AzureAccount.Property.MSILoginUri))
36+
{
37+
throw new ArgumentNullException(nameof(account));
38+
}
39+
40+
if (string.IsNullOrWhiteSpace(tenant))
41+
{
42+
throw new ArgumentNullException(nameof(tenant));
43+
}
44+
45+
if (environment == null)
46+
{
47+
throw new ArgumentNullException(nameof(environment));
48+
}
49+
50+
_account = account;
51+
_resourceId = GetResource(resourceId, environment);
52+
var baseUri = _account.GetProperty(AzureAccount.Property.MSILoginUri);
53+
var builder = new UriBuilder(baseUri);
54+
builder.Query = string.Format("resource={0}", Uri.EscapeDataString(_resourceId));
55+
_requestUri = builder.Uri.ToString();
56+
_tenant = tenant;
57+
IHttpOperationsFactory factory;
58+
if (!AzureSession.Instance.TryGetComponent(HttpClientOperationsFactory.Name, out factory))
59+
{
60+
factory = HttpClientOperationsFactory.Create();
61+
}
62+
63+
_tokenGetter = factory.GetHttpOperations<ManagedServiceTokenInfo>().WithHeader("Metadata", new[] { "true" });
64+
}
65+
66+
public string AccessToken
67+
{
68+
get
69+
{
70+
try
71+
{
72+
GetOrRenewAuthentication();
73+
}
74+
catch (HttpRequestException httpException)
75+
{
76+
throw new InvalidOperationException(string.Format(Resources.MSITokenRequestFailed, _resourceId, _requestUri), httpException);
77+
}
78+
79+
return _accessToken;
80+
}
81+
}
82+
83+
public string LoginType => "ManagedService";
84+
85+
public string TenantId => _tenant;
86+
87+
public string UserId => _account.Id;
88+
89+
public void AuthorizeRequest(Action<string, string> authTokenSetter)
90+
{
91+
authTokenSetter("Bearer", AccessToken);
92+
}
93+
94+
void GetOrRenewAuthentication()
95+
{
96+
if (_expiration - DateTime.UtcNow < TimeSpan.FromMinutes(5))
97+
{
98+
var info = _tokenGetter.GetAsync(_requestUri, CancellationToken.None).ConfigureAwait(false).GetAwaiter().GetResult();
99+
SetToken(info);
100+
}
101+
}
102+
103+
void SetToken(ManagedServiceTokenInfo info)
104+
{
105+
_expiration = DateTime.UtcNow + TimeSpan.FromSeconds(info.ExpiresIn);
106+
_accessToken = info.AccessToken;
107+
}
108+
109+
string GetResource(string endpointOrResource, IAzureEnvironment environment)
110+
{
111+
return environment.GetEndpoint(endpointOrResource) ?? endpointOrResource;
112+
}
113+
}
114+
}

0 commit comments

Comments
 (0)