diff --git a/source/Server.AzureAD/Configuration/AzureADConfiguration.cs b/source/Server.AzureAD/Configuration/AzureADConfiguration.cs index adfa681..53ae799 100644 --- a/source/Server.AzureAD/Configuration/AzureADConfiguration.cs +++ b/source/Server.AzureAD/Configuration/AzureADConfiguration.cs @@ -1,4 +1,5 @@ -using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Configuration; +using Octopus.Data.Model; +using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Configuration; namespace Octopus.Server.Extensibility.Authentication.AzureAD.Configuration { @@ -6,6 +7,8 @@ class AzureADConfiguration : OpenIDConnectConfigurationWithRole { public static string DefaultRoleClaimType = "roles"; + public SensitiveString? ClientSecret { get; set; } + public AzureADConfiguration() : base(AzureADConfigurationStore.SingletonId, "AzureAD", "Octopus Deploy", "1.0") { RoleClaimType = DefaultRoleClaimType; diff --git a/source/Server.AzureAD/Configuration/AzureADConfigurationResource.cs b/source/Server.AzureAD/Configuration/AzureADConfigurationResource.cs index f2f32ef..b3a0ae0 100644 --- a/source/Server.AzureAD/Configuration/AzureADConfigurationResource.cs +++ b/source/Server.AzureAD/Configuration/AzureADConfigurationResource.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Configuration; +using Octopus.Server.MessageContracts; using Octopus.Server.MessageContracts.Attributes; namespace Octopus.Server.Extensibility.Authentication.AzureAD.Configuration @@ -11,5 +12,10 @@ class AzureADConfigurationResource : OpenIDConnectConfigurationResource [Description("Tell Octopus how to find the roles/groups in the security token from Azure Active Directory (usually \"roles\" or \"groups\")")] [Writeable] public string? RoleClaimType { get; set; } + + [DisplayName("ClientSecret")] + [Description("A client secret from the Octopus App Registration in AzureAD. Used for authenticating to the Microsoft Graph API when necessary")] + [Writeable] + public SensitiveValue? ClientSecret { get; set; } } } \ No newline at end of file diff --git a/source/Server.AzureAD/Configuration/AzureADConfigurationSettings.cs b/source/Server.AzureAD/Configuration/AzureADConfigurationSettings.cs index 934f728..349eaf4 100644 --- a/source/Server.AzureAD/Configuration/AzureADConfigurationSettings.cs +++ b/source/Server.AzureAD/Configuration/AzureADConfigurationSettings.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using Octopus.Data.Model; using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Configuration; using Octopus.Server.Extensibility.Extensions.Infrastructure.Configuration; @@ -21,6 +22,7 @@ public override IEnumerable GetConfigurationValues() yield return configurationValue; } yield return new ConfigurationValue($"Octopus.{ConfigurationDocumentStore.ConfigurationSettingsName}.RoleClaimType", ConfigurationDocumentStore.GetRoleClaimType(), ConfigurationDocumentStore.GetIsEnabled() && ConfigurationDocumentStore.GetRoleClaimType() != AzureADConfiguration.DefaultRoleClaimType, "Role Claim Type"); + yield return new ConfigurationValue($"Octopus.{ConfigurationDocumentStore.ConfigurationSettingsName}.ClientSecret", ConfigurationDocumentStore.GetClientSecret(), ConfigurationDocumentStore.GetIsEnabled(), "ClientSecret"); } } diff --git a/source/Server.AzureAD/Configuration/AzureADConfigurationStore.cs b/source/Server.AzureAD/Configuration/AzureADConfigurationStore.cs index fc5de7d..a737c3e 100644 --- a/source/Server.AzureAD/Configuration/AzureADConfigurationStore.cs +++ b/source/Server.AzureAD/Configuration/AzureADConfigurationStore.cs @@ -1,4 +1,5 @@ -using Octopus.Data.Storage.Configuration; +using Octopus.Data.Model; +using Octopus.Data.Storage.Configuration; using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Configuration; namespace Octopus.Server.Extensibility.Authentication.AzureAD.Configuration @@ -15,5 +16,15 @@ public AzureADConfigurationStore( IConfigurationStore configurationStore) : base(configurationStore) { } + + public void SetClientSecret(SensitiveString? clientSecret) + { + SetProperty(doc => doc.ClientSecret = clientSecret); + } + + public SensitiveString? GetClientSecret() + { + return GetProperty(doc => doc.ClientSecret); + } } } \ No newline at end of file diff --git a/source/Server.AzureAD/Configuration/AzureADConfigureCommands.cs b/source/Server.AzureAD/Configuration/AzureADConfigureCommands.cs index d16971b..ee9dd4c 100644 --- a/source/Server.AzureAD/Configuration/AzureADConfigureCommands.cs +++ b/source/Server.AzureAD/Configuration/AzureADConfigureCommands.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Octopus.Data.Model; using Octopus.Diagnostics; using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Configuration; using Octopus.Server.Extensibility.Extensions.Infrastructure.Configuration; @@ -30,6 +31,14 @@ public override IEnumerable GetOptions() ConfigurationStore.Value.SetRoleClaimType(v); Log.Info($"{ConfigurationSettingsName} RoleClaimType set to: {v}"); }); + yield return new ConfigureCommandOption($"{ConfigurationSettingsName}ClientSecret=", "A client secret from the Octopus App Registration in AzureAD. Used for authenticating to the Microsoft Graph API when necessary", v => + { + if (!string.IsNullOrEmpty(v)) + { + ConfigurationStore.Value.SetClientSecret(v.ToSensitiveString()); + Log.Info($"{ConfigurationSettingsName} ClientSecret was set."); + } + }); } } } \ No newline at end of file diff --git a/source/Server.AzureAD/Configuration/IAzureADConfigurationStore.cs b/source/Server.AzureAD/Configuration/IAzureADConfigurationStore.cs index 9f1b8c5..577a2b8 100644 --- a/source/Server.AzureAD/Configuration/IAzureADConfigurationStore.cs +++ b/source/Server.AzureAD/Configuration/IAzureADConfigurationStore.cs @@ -1,8 +1,11 @@ -using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Configuration; +using Octopus.Data.Model; +using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Configuration; namespace Octopus.Server.Extensibility.Authentication.AzureAD.Configuration { interface IAzureADConfigurationStore : IOpenIDConnectConfigurationWithRoleStore { + SensitiveString? GetClientSecret(); + void SetClientSecret(SensitiveString? clientSecret); } } \ No newline at end of file diff --git a/source/Server.AzureAD/GraphApi/GraphApiClient.cs b/source/Server.AzureAD/GraphApi/GraphApiClient.cs new file mode 100644 index 0000000..47d7ddb --- /dev/null +++ b/source/Server.AzureAD/GraphApi/GraphApiClient.cs @@ -0,0 +1,76 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; + +namespace Octopus.Server.Extensibility.Authentication.AzureAD.GraphApi +{ + internal class GraphApiClient + { + private readonly HttpClient httpClient; + private readonly Uri tokenUri; + private readonly Guid clientId; + private readonly string clientSecret; + + private const string scope = "https://graph.microsoft.com/groupmember.read.all"; + private const string grantType = "urn:ietf:params:oauth:grant-type:jwt-bearer"; + private const string requestedTokenUse = "on_behalf_of"; + private const string graphQuerySelect = "$select=id,displayName,onPremisesNetBiosName,onPremisesDomainName,onPremisesSamAccountNameonPremisesSecurityIdentifier"; + + public GraphApiClient(HttpClient httpClient, Guid tenantId, Guid clientId, string? clientSecret) + { + this.httpClient = httpClient; + tokenUri = new Uri("https://login.microsoftonline.com/" + tenantId.ToString() + "/oauth2/v2.0/token"); + this.clientId = clientId; + this.clientSecret = clientSecret ?? throw new ArgumentNullException(nameof(clientSecret)); + } + + public async Task GetAccessTokenOnBehalfOfUser(string assertion) + { + var requestBody = new FormUrlEncodedContent(new[] + { + new KeyValuePair("grant_type", grantType), + new KeyValuePair("client_id", clientId.ToString()), + new KeyValuePair("client_secret", clientSecret), + new KeyValuePair("assertion", assertion), + new KeyValuePair("scope", scope), + new KeyValuePair("requested_token_use", requestedTokenUse) + }); + + var response = await httpClient.PostAsync(tokenUri, requestBody); + response.EnsureSuccessStatusCode(); + var responseBody = await response.Content.ReadAsStringAsync(); + var model = JsonConvert.DeserializeObject(responseBody); + + return model.AccessToken; + } + + public async Task GetGroupMembershipIds(string accessToken) + { + var groups = new HashSet(); + string? nextLink = null; + do + { + var uri = string.IsNullOrEmpty(nextLink) ? ("https://graph.microsoft.com/v1.0/me/memberOf/microsoft.graph.group?" + graphQuerySelect) : nextLink; + // The nextLink will contain all other query parameters from original request: https://docs.microsoft.com/en-us/graph/paging?context=graph%2Fapi%2F1.0&view=graph-rest-1.0 + using (var request = new HttpRequestMessage(HttpMethod.Get, uri)) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + var response = await httpClient.SendAsync(request); + response.EnsureSuccessStatusCode(); + var responseBody = await response.Content.ReadAsStringAsync(); + var graphObjects = JsonConvert.DeserializeObject(responseBody); + nextLink = graphObjects.NextLink; + + groups.UnionWith(graphObjects.Value.Select(m => m.Id)); + } + } while (!string.IsNullOrEmpty(nextLink)); + + return groups.ToArray(); + } + } +} diff --git a/source/Server.AzureAD/GraphApi/GraphResponse.cs b/source/Server.AzureAD/GraphApi/GraphResponse.cs new file mode 100644 index 0000000..2d510c6 --- /dev/null +++ b/source/Server.AzureAD/GraphApi/GraphResponse.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; + +namespace Octopus.Server.Extensibility.Authentication.AzureAD.GraphApi +{ + internal class GraphResponse + { + [JsonProperty("@odata.context")] + public string? Context { get; set; } + [JsonProperty("@odata.nextLink")] + public string? NextLink { get; set; } + [JsonProperty("value")] + public MembershipEntity[]? Value { get; set; } + } +} diff --git a/source/Server.AzureAD/GraphApi/MembershipEntity.cs b/source/Server.AzureAD/GraphApi/MembershipEntity.cs new file mode 100644 index 0000000..3eafe4c --- /dev/null +++ b/source/Server.AzureAD/GraphApi/MembershipEntity.cs @@ -0,0 +1,11 @@ +using Newtonsoft.Json; +using System; + +namespace Octopus.Server.Extensibility.Authentication.AzureAD.GraphApi +{ + internal class MembershipEntity + { + [JsonProperty("id")] + public string Id { get; set; } = string.Empty; + } +} diff --git a/source/Server.AzureAD/GraphApi/TokenResponse.cs b/source/Server.AzureAD/GraphApi/TokenResponse.cs new file mode 100644 index 0000000..6b51bb9 --- /dev/null +++ b/source/Server.AzureAD/GraphApi/TokenResponse.cs @@ -0,0 +1,23 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Text; + +namespace Octopus.Server.Extensibility.Authentication.AzureAD.GraphApi +{ + internal class TokenResponse + { + [JsonProperty("token_type")] + public string TokenType { get; set; } = string.Empty; + [JsonProperty("scope")] + public string Scope { get; set; } = string.Empty; + [JsonProperty("expires_in")] + public int ExpiresIn { get; set; } + [JsonProperty("ext_expires_in")] + public int ExtExpiresIn { get; set; } + [JsonProperty("access_token")] + public string AccessToken { get; set; } = string.Empty; + [JsonProperty("refresh_token")] + public string RefreshToken { get; set; } = string.Empty; + } +} diff --git a/source/Server.AzureAD/Tokens/AzureADAuthTokenHandler.cs b/source/Server.AzureAD/Tokens/AzureADAuthTokenHandler.cs index 0a48b22..0a6d43d 100644 --- a/source/Server.AzureAD/Tokens/AzureADAuthTokenHandler.cs +++ b/source/Server.AzureAD/Tokens/AzureADAuthTokenHandler.cs @@ -1,15 +1,55 @@ using Octopus.Diagnostics; using Octopus.Server.Extensibility.Authentication.AzureAD.Configuration; +using Octopus.Server.Extensibility.Authentication.AzureAD.GraphApi; using Octopus.Server.Extensibility.Authentication.AzureAD.Issuer; using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Issuer; using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Tokens; +using System; +using System.Linq; +using System.Net.Http; +using System.Security.Claims; +using System.Threading.Tasks; namespace Octopus.Server.Extensibility.Authentication.AzureAD.Tokens { class AzureADAuthTokenHandler : OpenIDConnectAuthTokenWithRolesHandler, IAzureADAuthTokenHandler { + private readonly IAzureADConfigurationStore configurationStore; + public AzureADAuthTokenHandler(ISystemLog log, IAzureADConfigurationStore configurationStore, IIdentityProviderConfigDiscoverer identityProviderConfigDiscoverer, IAzureADKeyRetriever keyRetriever) : base(log, configurationStore, identityProviderConfigDiscoverer, keyRetriever) { + this.configurationStore = configurationStore; + } + + protected override string[] GetProviderGroupIds(ClaimsPrincipal principal, string? assertion = null) + => (SupportsHandlingOverages() && HasOverageOccurred(principal)) + ? GetProviderGroupIdsAsync(principal, assertion).Result + : base.GetProviderGroupIds(principal); + + private async Task GetProviderGroupIdsAsync(ClaimsPrincipal principal, string? idToken) + { + using (var httpClient = new HttpClient()) + { + var graphClient = new GraphApiClient( + httpClient, + GetTenantIdFromIssuer(configurationStore.GetIssuer()), + Guid.Parse(configurationStore.GetClientId()), + configurationStore.GetClientSecret()?.Value + ); + + var bearerToken = await graphClient.GetAccessTokenOnBehalfOfUser(idToken!); + return await graphClient.GetGroupMembershipIds(bearerToken); + } + } + + private bool SupportsHandlingOverages() => !string.IsNullOrEmpty(configurationStore.GetClientSecret()?.Value); + + private static bool HasOverageOccurred(ClaimsPrincipal identity) => identity.Claims.Any(x => x.Type == "hasgroups" || (x.Type == "_claim_names" && x.Value == "{\"groups\":\"src1\"}")); + + private static Guid GetTenantIdFromIssuer(string? issuer) + { + var uri = new Uri(issuer); + return Guid.Parse(uri.Segments.Last()); } } } \ No newline at end of file diff --git a/source/Server.OpenIDConnect.Common/Tokens/AuthTokenHandler.cs b/source/Server.OpenIDConnect.Common/Tokens/AuthTokenHandler.cs index 5383a15..7b1f45c 100644 --- a/source/Server.OpenIDConnect.Common/Tokens/AuthTokenHandler.cs +++ b/source/Server.OpenIDConnect.Common/Tokens/AuthTokenHandler.cs @@ -81,7 +81,7 @@ protected async Task GetPrincipalFromToken(string? acc DoIssuerSpecificClaimsValidation(principal, out error); if (string.IsNullOrWhiteSpace(error)) - return new ClaimsPrincipleContainer(principal, GetProviderGroupIds(principal)); + return new ClaimsPrincipleContainer(principal, GetProviderGroupIds(principal, tokenToValidate)); return new ClaimsPrincipleContainer(error); } @@ -172,7 +172,7 @@ protected virtual void DoIssuerSpecificClaimsValidation(ClaimsPrincipal principa error = string.Empty; } - protected virtual string[] GetProviderGroupIds(ClaimsPrincipal principal) + protected virtual string[] GetProviderGroupIds(ClaimsPrincipal principal, string? idToken = null) { return new string[0]; } diff --git a/source/Server.OpenIDConnect.Common/Tokens/OpenIDConnectAuthTokenWithRolesHandler.cs b/source/Server.OpenIDConnect.Common/Tokens/OpenIDConnectAuthTokenWithRolesHandler.cs index d75b2a9..65df1ee 100644 --- a/source/Server.OpenIDConnect.Common/Tokens/OpenIDConnectAuthTokenWithRolesHandler.cs +++ b/source/Server.OpenIDConnect.Common/Tokens/OpenIDConnectAuthTokenWithRolesHandler.cs @@ -23,7 +23,7 @@ protected override void SetIssuerSpecificTokenValidationParameters(TokenValidati validationParameters.RoleClaimType = ConfigurationStore.GetRoleClaimType(); } - protected override string[] GetProviderGroupIds(ClaimsPrincipal principal) + protected override string[] GetProviderGroupIds(ClaimsPrincipal principal, string? idToken = null) { var roleClaimType = ConfigurationStore.GetRoleClaimType();