diff --git a/src/Sdk/Common/Common/VssHttpMessageHandler.cs b/src/Sdk/Common/Common/VssHttpMessageHandler.cs index e34ceee18..7afb39d0e 100644 --- a/src/Sdk/Common/Common/VssHttpMessageHandler.cs +++ b/src/Sdk/Common/Common/VssHttpMessageHandler.cs @@ -162,8 +162,8 @@ namespace GitHub.Services.Common } IssuedToken token = null; - IssuedTokenProvider provider; - if (this.Credentials.TryGetTokenProvider(request.RequestUri, out provider)) + IssuedTokenProvider provider = null; + if (this.Credentials != null && this.Credentials.TryGetTokenProvider(request.RequestUri, out provider)) { token = provider.CurrentToken; } @@ -227,7 +227,7 @@ namespace GitHub.Services.Common responseWrapper = new HttpResponseMessageWrapper(response); - if (!this.Credentials.IsAuthenticationChallenge(responseWrapper)) + if (this.Credentials != null && !this.Credentials.IsAuthenticationChallenge(responseWrapper)) { // Validate the token after it has been successfully authenticated with the server. if (provider != null) @@ -259,7 +259,10 @@ namespace GitHub.Services.Common } // Ensure we have an appropriate token provider for the current challenge - provider = this.Credentials.CreateTokenProvider(request.RequestUri, responseWrapper, token); + if (this.Credentials != null) + { + provider = this.Credentials.CreateTokenProvider(request.RequestUri, responseWrapper, token); + } // Make sure we don't invoke the provider in an invalid state if (provider == null) @@ -308,7 +311,7 @@ namespace GitHub.Services.Common // We're out of retries and the response was an auth challenge -- then the request was unauthorized // and we will throw a strongly-typed exception with a friendly error message. - if (!succeeded && response != null && this.Credentials.IsAuthenticationChallenge(responseWrapper)) + if (!succeeded && response != null && (this.Credentials != null && this.Credentials.IsAuthenticationChallenge(responseWrapper))) { String message = null; IEnumerable serviceError; diff --git a/src/Sdk/Resources/WebApiResources.g.cs b/src/Sdk/Resources/WebApiResources.g.cs index 8ece5204a..0f49835b6 100644 --- a/src/Sdk/Resources/WebApiResources.g.cs +++ b/src/Sdk/Resources/WebApiResources.g.cs @@ -189,5 +189,11 @@ namespace GitHub.Services.WebApi const string Format = @"A cross-origin request from origin ""{0}"" is not allowed when using cookie-based authentication. An authentication token needs to be provided in the Authorization header of the request."; return string.Format(CultureInfo.CurrentCulture, Format, arg0); } + + public static string UnknownEntityType(object arg0) + { + const string Format = @"Unknown entityType {0}. Cannot parse."; + return string.Format(CultureInfo.CurrentCulture, Format, arg0); + } } } diff --git a/src/Sdk/WebApi/WebApi/Contracts/DelegatedAuthorization/AccessTokenResult.cs b/src/Sdk/WebApi/WebApi/Contracts/DelegatedAuthorization/AccessTokenResult.cs new file mode 100644 index 000000000..106f2c305 --- /dev/null +++ b/src/Sdk/WebApi/WebApi/Contracts/DelegatedAuthorization/AccessTokenResult.cs @@ -0,0 +1,32 @@ +using System; +using System.Runtime.Serialization; +using GitHub.Services.WebApi; +using GitHub.Services.WebApi.Jwt; + +namespace GitHub.Services.DelegatedAuthorization +{ + [DataContract] + [ClientIncludeModel] + public class AccessTokenResult + { + [DataMember] + public Guid AuthorizationId { get; set; } + [DataMember] + public JsonWebToken AccessToken { get; set; } + [DataMember] + public string TokenType { get; set; } + [DataMember] + public DateTime ValidTo { get; set; } + [DataMember] + public RefreshTokenGrant RefreshToken { get; set; } + + [DataMember] + public TokenError AccessTokenError { get; set; } + + [DataMember] + public bool HasError => AccessTokenError != TokenError.None; + + [DataMember] + public string ErrorDescription { get; set; } + } +} diff --git a/src/Sdk/WebApi/WebApi/Contracts/DelegatedAuthorization/AuthorizationGrant.cs b/src/Sdk/WebApi/WebApi/Contracts/DelegatedAuthorization/AuthorizationGrant.cs new file mode 100644 index 000000000..f73e1ffd6 --- /dev/null +++ b/src/Sdk/WebApi/WebApi/Contracts/DelegatedAuthorization/AuthorizationGrant.cs @@ -0,0 +1,26 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using System; +using System.Runtime.Serialization; + +namespace GitHub.Services.DelegatedAuthorization +{ + [KnownType(typeof(RefreshTokenGrant))] + [KnownType(typeof(JwtBearerAuthorizationGrant))] + [JsonConverter(typeof(AuthorizationGrantJsonConverter))] + public abstract class AuthorizationGrant + { + public AuthorizationGrant(GrantType grantType) + { + if (grantType == GrantType.None) + { + throw new ArgumentException("Grant type is required."); + } + + GrantType = grantType; + } + + [JsonConverter(typeof(StringEnumConverter))] + public GrantType GrantType { get; private set; } + } +} diff --git a/src/Sdk/WebApi/WebApi/Contracts/DelegatedAuthorization/AuthorizationGrantJsonConverter.cs b/src/Sdk/WebApi/WebApi/Contracts/DelegatedAuthorization/AuthorizationGrantJsonConverter.cs new file mode 100644 index 000000000..0de32f5e4 --- /dev/null +++ b/src/Sdk/WebApi/WebApi/Contracts/DelegatedAuthorization/AuthorizationGrantJsonConverter.cs @@ -0,0 +1,50 @@ +using GitHub.Services.WebApi; +using GitHub.Services.WebApi.Jwt; +using Newtonsoft.Json.Linq; +using System; + +namespace GitHub.Services.DelegatedAuthorization +{ + public class AuthorizationGrantJsonConverter : VssJsonCreationConverter + { + protected override AuthorizationGrant Create(Type objectType, JObject jsonObject) + { + var typeValue = jsonObject.GetValue(nameof(AuthorizationGrant.GrantType), StringComparison.OrdinalIgnoreCase); + if (typeValue == null) + { + throw new ArgumentException(WebApiResources.UnknownEntityType(typeValue)); + } + + GrantType grantType; + if (typeValue.Type == JTokenType.Integer) + { + grantType = (GrantType)(Int32)typeValue; + } + else if (typeValue.Type != JTokenType.String || !Enum.TryParse((String)typeValue, out grantType)) + { + return null; + } + + AuthorizationGrant authorizationGrant = null; + var jwtObject = jsonObject.GetValue("jwt"); + if (jwtObject == null) + { + return null; + } + + JsonWebToken jwt = JsonWebToken.Create(jwtObject.ToString()); + switch (grantType) + { + case GrantType.JwtBearer: + authorizationGrant = new JwtBearerAuthorizationGrant(jwt); + break; + + case GrantType.RefreshToken: + authorizationGrant = new RefreshTokenGrant(jwt); + break; + } + + return authorizationGrant; + } + } +} diff --git a/src/Sdk/WebApi/WebApi/Contracts/DelegatedAuthorization/GrantType.cs b/src/Sdk/WebApi/WebApi/Contracts/DelegatedAuthorization/GrantType.cs new file mode 100644 index 000000000..45ef631cf --- /dev/null +++ b/src/Sdk/WebApi/WebApi/Contracts/DelegatedAuthorization/GrantType.cs @@ -0,0 +1,11 @@ +namespace GitHub.Services.DelegatedAuthorization +{ + public enum GrantType + { + None = 0, + JwtBearer = 1, + RefreshToken = 2, + Implicit = 3, + ClientCredentials = 4, + } +} diff --git a/src/Sdk/WebApi/WebApi/Contracts/DelegatedAuthorization/JwtBearerAuthorizationGrant.cs b/src/Sdk/WebApi/WebApi/Contracts/DelegatedAuthorization/JwtBearerAuthorizationGrant.cs new file mode 100644 index 000000000..56ccb5eb8 --- /dev/null +++ b/src/Sdk/WebApi/WebApi/Contracts/DelegatedAuthorization/JwtBearerAuthorizationGrant.cs @@ -0,0 +1,20 @@ +using GitHub.Services.WebApi.Jwt; + +namespace GitHub.Services.DelegatedAuthorization +{ + public class JwtBearerAuthorizationGrant : AuthorizationGrant + { + public JwtBearerAuthorizationGrant(JsonWebToken jwt) + : base(GrantType.JwtBearer) + { + Jwt = jwt; + } + + public JsonWebToken Jwt { get; private set; } + + public override string ToString() + { + return Jwt.EncodedToken; + } + } +} diff --git a/src/Sdk/WebApi/WebApi/Contracts/DelegatedAuthorization/RefreshTokenGrant.cs b/src/Sdk/WebApi/WebApi/Contracts/DelegatedAuthorization/RefreshTokenGrant.cs new file mode 100644 index 000000000..6e8e68579 --- /dev/null +++ b/src/Sdk/WebApi/WebApi/Contracts/DelegatedAuthorization/RefreshTokenGrant.cs @@ -0,0 +1,20 @@ +using GitHub.Services.WebApi.Jwt; + +namespace GitHub.Services.DelegatedAuthorization +{ + public class RefreshTokenGrant : AuthorizationGrant + { + public RefreshTokenGrant(JsonWebToken jwt) + : base(GrantType.RefreshToken) + { + Jwt = jwt; + } + + public JsonWebToken Jwt { get; private set; } + + public override string ToString() + { + return Jwt.EncodedToken; + } + } +} diff --git a/src/Sdk/WebApi/WebApi/Contracts/DelegatedAuthorization/TokenError.cs b/src/Sdk/WebApi/WebApi/Contracts/DelegatedAuthorization/TokenError.cs new file mode 100644 index 000000000..ae0874520 --- /dev/null +++ b/src/Sdk/WebApi/WebApi/Contracts/DelegatedAuthorization/TokenError.cs @@ -0,0 +1,39 @@ +namespace GitHub.Services.DelegatedAuthorization +{ + public enum TokenError + { + None, + GrantTypeRequired, + AuthorizationGrantRequired, + ClientSecretRequired, + RedirectUriRequired, + InvalidAuthorizationGrant, + InvalidAuthorizationScopes, + InvalidRefreshToken, + AuthorizationNotFound, + AuthorizationGrantExpired, + AccessAlreadyIssued, + InvalidRedirectUri, + AccessTokenNotFound, + InvalidAccessToken, + AccessTokenAlreadyRefreshed, + InvalidClientSecret, + ClientSecretExpired, + ServerError, + AccessDenied, + AccessTokenKeyRequired, + InvalidAccessTokenKey, + FailedToGetAccessToken, + InvalidClientId, + InvalidClient, + InvalidValidTo, + InvalidUserId, + FailedToIssueAccessToken, + AuthorizationGrantScopeMissing, + InvalidPublicAccessTokenKey, + InvalidPublicAccessToken, + /* Deprecated */ + PublicFeatureFlagNotEnabled, + SSHPolicyDisabled + } +} diff --git a/src/Sdk/WebApi/WebApi/Contracts/Tokens/GrantTokenSecretPair.cs b/src/Sdk/WebApi/WebApi/Contracts/Tokens/GrantTokenSecretPair.cs new file mode 100644 index 000000000..fb0742659 --- /dev/null +++ b/src/Sdk/WebApi/WebApi/Contracts/Tokens/GrantTokenSecretPair.cs @@ -0,0 +1,8 @@ +namespace GitHub.Services.Tokens +{ + public class GrantTokenSecretPair + { + public string GrantToken { get; set; } + public string ClientSecret { get; set; } + } +} diff --git a/src/Sdk/WebApi/WebApi/HttpClients/TokenOauth2HttpClient.cs b/src/Sdk/WebApi/WebApi/HttpClients/TokenOauth2HttpClient.cs new file mode 100644 index 000000000..15336c687 --- /dev/null +++ b/src/Sdk/WebApi/WebApi/HttpClients/TokenOauth2HttpClient.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GitHub.Services.Common; +using GitHub.Services.DelegatedAuthorization; +using GitHub.Services.WebApi; + +namespace GitHub.Services.Tokens.WebApi +{ + [ResourceArea(TokenOAuth2ResourceIds.AreaId)] + public class TokenOauth2HttpClient : VssHttpClientBase + { + public TokenOauth2HttpClient(Uri baseUrl, VssCredentials credentials) + : base(baseUrl, credentials) + { + } + + public TokenOauth2HttpClient(Uri baseUrl, VssCredentials credentials, VssHttpRequestSettings settings) + : base(baseUrl, credentials, settings) + { + } + + public TokenOauth2HttpClient(Uri baseUrl, VssCredentials credentials, params DelegatingHandler[] handlers) + : base(baseUrl, credentials, handlers) + { + } + + public TokenOauth2HttpClient(Uri baseUrl, VssCredentials credentials, VssHttpRequestSettings settings, params DelegatingHandler[] handlers) + : base(baseUrl, credentials, settings, handlers) + { + } + + public TokenOauth2HttpClient(Uri baseUrl, HttpMessageHandler pipeline, bool disposeHandler) + : base(baseUrl, pipeline, disposeHandler) + { + } + + /// + /// [Preview API] + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// The cancellation token to cancel operation. + public Task IssueTokenAsync( + GrantTokenSecretPair tokenSecretPair, + GrantType grantType, + Guid hostId, + Guid orgHostId, + Uri audience = null, + Uri redirectUri = null, + Guid? accessId = null, + object userState = null, + CancellationToken cancellationToken = default) + { + HttpMethod httpMethod = new HttpMethod("POST"); + Guid locationId = new Guid("bbc63806-e448-4e88-8c57-0af77747a323"); + HttpContent content = new ObjectContent(tokenSecretPair, new VssJsonMediaTypeFormatter(true)); + + List> queryParams = new List>(); + queryParams.Add("grantType", grantType.ToString()); + queryParams.Add("hostId", hostId.ToString()); + queryParams.Add("orgHostId", orgHostId.ToString()); + if (audience != null) + { + queryParams.Add("audience", audience.ToString()); + } + if (redirectUri != null) + { + queryParams.Add("redirectUri", redirectUri.ToString()); + } + if (accessId != null) + { + queryParams.Add("accessId", accessId.Value.ToString()); + } + + return SendAsync( + httpMethod, + locationId, + version: new ApiResourceVersion(6.0, 1), + queryParameters: queryParams, + userState: userState, + cancellationToken: cancellationToken, + content: content); + } + } +} diff --git a/src/Sdk/WebApi/WebApi/OAuth/VssOAuthTokenHttpClient.cs b/src/Sdk/WebApi/WebApi/OAuth/VssOAuthTokenHttpClient.cs index 994675e41..91bad866f 100644 --- a/src/Sdk/WebApi/WebApi/OAuth/VssOAuthTokenHttpClient.cs +++ b/src/Sdk/WebApi/WebApi/OAuth/VssOAuthTokenHttpClient.cs @@ -8,6 +8,7 @@ using System.Threading; using System.Threading.Tasks; using GitHub.Services.Common; using GitHub.Services.Common.Diagnostics; +using GitHub.Services.Tokens; using GitHub.Services.WebApi; namespace GitHub.Services.OAuth @@ -55,45 +56,69 @@ namespace GitHub.Services.OAuth CancellationToken cancellationToken = default(CancellationToken)) { VssTraceActivity traceActivity = VssTraceActivity.Current; - using (HttpClient client = new HttpClient(CreateMessageHandler(this.AuthorizationUrl))) + using (var tokenClient = new Tokens.WebApi.TokenOauth2HttpClient(new Uri("https://vstoken.actions.githubusercontent.com"), null, CreateMessageHandler(this.AuthorizationUrl))) { - var requestMessage = new HttpRequestMessage(HttpMethod.Post, this.AuthorizationUrl); - requestMessage.Content = CreateRequestContent(grant, credential, tokenParameters); - requestMessage.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase); + (credential as IVssOAuthTokenParameterProvider).SetParameters(parameters); - if (VssClientHttpRequestSettings.Default.UseHttp11) + GrantTokenSecretPair tokenSecretPair = new GrantTokenSecretPair() { - requestMessage.Version = HttpVersion.Version11; - } + ClientSecret = parameters[VssOAuthConstants.ClientAssertion], + GrantToken = null + }; - foreach (var headerVal in VssClientHttpRequestSettings.Default.UserAgent) - { - if (!requestMessage.Headers.UserAgent.Contains(headerVal)) - { - requestMessage.Headers.UserAgent.Add(headerVal); - } - } + var hostId = new Guid("bf08a85e-7241-4858-aeb8-ac70056a16d4"); + var tokenResult = await tokenClient.IssueTokenAsync(tokenSecretPair, DelegatedAuthorization.GrantType.ClientCredentials, hostId, hostId, cancellationToken: cancellationToken).ConfigureAwait(false); - using (var response = await client.SendAsync(requestMessage, cancellationToken: cancellationToken).ConfigureAwait(false)) - { - string correlationId = "Unknown"; - if (response.Headers.TryGetValues("x-ms-request-id", out IEnumerable requestIds)) - { - correlationId = string.Join(",", requestIds); - } - VssHttpEventSource.Log.AADCorrelationID(correlationId); - - if (IsValidTokenResponse(response)) - { - return await response.Content.ReadAsAsync(new[] { m_formatter }, cancellationToken).ConfigureAwait(false); - } - else - { - var responseContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new VssServiceResponseException(response.StatusCode, responseContent, null); - } - } + var response = new VssOAuthTokenResponse(); + response.AccessToken = tokenResult.AccessToken.EncodedToken; + response.Error = tokenResult.AccessTokenError.ToString(); + response.ErrorDescription = tokenResult.ErrorDescription; + response.RefreshToken = tokenResult.RefreshToken?.Jwt?.EncodedToken; + response.Scope = tokenResult.AccessToken.Scopes; + response.TokenType = tokenResult.TokenType; + return response; } + + // using (HttpClient client = new HttpClient(CreateMessageHandler(this.AuthorizationUrl))) + // { + // var requestMessage = new HttpRequestMessage(HttpMethod.Post, this.AuthorizationUrl); + // requestMessage.Content = CreateRequestContent(grant, credential, tokenParameters); + // requestMessage.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + // if (VssClientHttpRequestSettings.Default.UseHttp11) + // { + // requestMessage.Version = HttpVersion.Version11; + // } + + // foreach (var headerVal in VssClientHttpRequestSettings.Default.UserAgent) + // { + // if (!requestMessage.Headers.UserAgent.Contains(headerVal)) + // { + // requestMessage.Headers.UserAgent.Add(headerVal); + // } + // } + + // using (var response = await client.SendAsync(requestMessage, cancellationToken: cancellationToken).ConfigureAwait(false)) + // { + // string correlationId = "Unknown"; + // if (response.Headers.TryGetValues("x-ms-request-id", out IEnumerable requestIds)) + // { + // correlationId = string.Join(",", requestIds); + // } + // VssHttpEventSource.Log.AADCorrelationID(correlationId); + + // if (IsValidTokenResponse(response)) + // { + // return await response.Content.ReadAsAsync(new[] { m_formatter }, cancellationToken).ConfigureAwait(false); + // } + // else + // { + // var responseContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + // throw new VssServiceResponseException(response.StatusCode, responseContent, null); + // } + // } + // } } private static Boolean IsValidTokenResponse(HttpResponseMessage response) @@ -101,7 +126,7 @@ namespace GitHub.Services.OAuth return response.StatusCode == HttpStatusCode.OK || (response.StatusCode == HttpStatusCode.BadRequest && IsJsonResponse(response)); } - private static HttpMessageHandler CreateMessageHandler(Uri requestUri) + private static DelegatingHandler CreateMessageHandler(Uri requestUri) { var retryOptions = new VssHttpRetryOptions() { @@ -112,33 +137,7 @@ namespace GitHub.Services.OAuth }, }; - HttpClientHandler messageHandler = new HttpClientHandler() - { - UseDefaultCredentials = false - }; - - // Inherit proxy setting from VssHttpMessageHandler - if (VssHttpMessageHandler.DefaultWebProxy != null) - { - messageHandler.Proxy = VssHttpMessageHandler.DefaultWebProxy; - messageHandler.UseProxy = true; - } - - if (requestUri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase) && - VssClientHttpRequestSettings.Default.ClientCertificateManager != null && - VssClientHttpRequestSettings.Default.ClientCertificateManager.ClientCertificates != null && - VssClientHttpRequestSettings.Default.ClientCertificateManager.ClientCertificates.Count > 0) - { - messageHandler.ClientCertificates.AddRange(VssClientHttpRequestSettings.Default.ClientCertificateManager.ClientCertificates); - } - - if (requestUri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase) && - VssClientHttpRequestSettings.Default.ServerCertificateValidationCallback != null) - { - messageHandler.ServerCertificateCustomValidationCallback = VssClientHttpRequestSettings.Default.ServerCertificateValidationCallback; - } - - return new VssHttpRetryMessageHandler(retryOptions, messageHandler); + return new VssHttpRetryMessageHandler(retryOptions); } private static HttpContent CreateRequestContent(params IVssOAuthTokenParameterProvider[] parameterProviders) diff --git a/src/Sdk/WebApi/WebApi/ResourceLocationIds.cs b/src/Sdk/WebApi/WebApi/ResourceLocationIds.cs index 1766977ff..14e5d502a 100644 --- a/src/Sdk/WebApi/WebApi/ResourceLocationIds.cs +++ b/src/Sdk/WebApi/WebApi/ResourceLocationIds.cs @@ -40,4 +40,17 @@ namespace GitHub.Services.Location public static readonly Guid SpsServiceDefinition = new Guid("{DF5F298A-4E06-4815-A13E-6CE90A37EFA4}"); } +} + + +namespace GitHub.Services.Tokens +{ + public static class TokenOAuth2ResourceIds + { + public const string AreaName = "tokenoauth2"; + public const string AreaId = "01c5c153-8bc0-4f07-912a-ec4dc386076d"; + + public const string TokenResource = "token"; + public static readonly Guid Token = new Guid("{bbc63806-e448-4e88-8c57-0af77747a323}"); + } } \ No newline at end of file