diff --git a/source/Client.OctopusID/Configuration/OctopusIDConfigurationResource.cs b/source/Client.OctopusID/Configuration/OctopusIDConfigurationResource.cs index 75d3da0..2df8795 100644 --- a/source/Client.OctopusID/Configuration/OctopusIDConfigurationResource.cs +++ b/source/Client.OctopusID/Configuration/OctopusIDConfigurationResource.cs @@ -5,7 +5,7 @@ namespace Octopus.Client.Extensibility.Authentication.OctopusID.Configuration { [Description("Sign in to your Octopus Server with your Octopus ID. [Learn more](https://g.octopushq.com/AuthOctopusID).")] - public class OctopusIDConfigurationResource : OpenIDConnectConfigurationWithClientSecretResource + public class OctopusIDConfigurationResource : OpenIDConnectConfigurationResource { public OctopusIDConfigurationResource() { diff --git a/source/Client.OpenIDConnect/Client.OpenIDConnect.csproj b/source/Client.OpenIDConnect/Client.OpenIDConnect.csproj index 48274b0..88580ae 100644 --- a/source/Client.OpenIDConnect/Client.OpenIDConnect.csproj +++ b/source/Client.OpenIDConnect/Client.OpenIDConnect.csproj @@ -20,8 +20,8 @@ - - + + diff --git a/source/Client.OpenIDConnect/Configuration/OpenIDConnectConfigurationResource.cs b/source/Client.OpenIDConnect/Configuration/OpenIDConnectConfigurationResource.cs index 7119f0f..fc2c309 100644 --- a/source/Client.OpenIDConnect/Configuration/OpenIDConnectConfigurationResource.cs +++ b/source/Client.OpenIDConnect/Configuration/OpenIDConnectConfigurationResource.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using Octopus.Client.Extensibility.Attributes; using Octopus.Client.Extensibility.Extensions.Infrastructure.Configuration; +using Octopus.Client.Model; namespace Octopus.Client.Extensibility.Authentication.OpenIDConnect.Configuration { @@ -15,6 +16,11 @@ public class OpenIDConnectConfigurationResource : ExtensionConfigurationResource [Writeable] public string ClientId { get; set; } + [DisplayName("Client Secret")] + [Description("Follow our documentation to find the Client Secret for your identity provider")] + [Writeable] + public SensitiveValue ClientSecret { get; set; } + [Writeable] [Description("Only change this if you need to change the OpenID Connect scope requested by Octopus")] public string Scope { get; set; } diff --git a/source/Client.OpenIDConnect/Configuration/OpenIDConnectConfigurationWithClientSecretResource.cs b/source/Client.OpenIDConnect/Configuration/OpenIDConnectConfigurationWithClientSecretResource.cs deleted file mode 100644 index 230f2d3..0000000 --- a/source/Client.OpenIDConnect/Configuration/OpenIDConnectConfigurationWithClientSecretResource.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.ComponentModel; -using Octopus.Client.Extensibility.Attributes; -using Octopus.Client.Model; - -namespace Octopus.Client.Extensibility.Authentication.OpenIDConnect.Configuration -{ - public class OpenIDConnectConfigurationWithClientSecretResource : OpenIDConnectConfigurationResource - { - [DisplayName("Client Secret")] - [Description("Follow our documentation to find the Client Secret for your identity provider")] - [Writeable] - public SensitiveValue ClientSecret { get; set; } - - } -} \ No newline at end of file diff --git a/source/Server.AzureAD/AzureADApi.cs b/source/Server.AzureAD/AzureADApi.cs index 5cc1a15..91ca208 100644 --- a/source/Server.AzureAD/AzureADApi.cs +++ b/source/Server.AzureAD/AzureADApi.cs @@ -16,6 +16,7 @@ public AzureADApi( { Add("POST", authenticationProvider.AuthenticateUri, RouteCategory.Raw, new AnonymousWhenEnabledEndpointInvocation(), null, "OpenIDConnect"); Add("POST", configurationStore.RedirectUri, RouteCategory.Raw, new AnonymousWhenEnabledEndpointInvocation(), null, "OpenIDConnect"); + Add("GET", configurationStore.RedirectUri, RouteCategory.Raw, new AnonymousWhenEnabledEndpointInvocation(), null, "OpenIDConnect"); } } } \ No newline at end of file diff --git a/source/Server.AzureAD/AzureADExtension.cs b/source/Server.AzureAD/AzureADExtension.cs index 6ade59d..71bf19d 100644 --- a/source/Server.AzureAD/AzureADExtension.cs +++ b/source/Server.AzureAD/AzureADExtension.cs @@ -60,6 +60,7 @@ public override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerDependency(); builder.RegisterType().AsSelf().InstancePerDependency(); + builder.RegisterType().AsSelf().InstancePerDependency(); builder.RegisterType().AsSelf().InstancePerDependency(); builder.RegisterType() diff --git a/source/Server.AzureAD/Configuration/AzureADConfigurationSettings.cs b/source/Server.AzureAD/Configuration/AzureADConfigurationSettings.cs index 934f728..9f05b52 100644 --- a/source/Server.AzureAD/Configuration/AzureADConfigurationSettings.cs +++ b/source/Server.AzureAD/Configuration/AzureADConfigurationSettings.cs @@ -4,7 +4,7 @@ namespace Octopus.Server.Extensibility.Authentication.AzureAD.Configuration { - class AzureADConfigurationSettings : OpenIdConnectConfigurationSettings, IAzureADConfigurationSettings + class AzureADConfigurationSettings : OpenIDConnectConfigurationSettings, IAzureADConfigurationSettings { public AzureADConfigurationSettings(IAzureADConfigurationStore configurationDocumentStore) : base(configurationDocumentStore) { diff --git a/source/Server.AzureAD/Configuration/AzureADConfigurationStore.cs b/source/Server.AzureAD/Configuration/AzureADConfigurationStore.cs index fc5de7d..5c666d6 100644 --- a/source/Server.AzureAD/Configuration/AzureADConfigurationStore.cs +++ b/source/Server.AzureAD/Configuration/AzureADConfigurationStore.cs @@ -3,7 +3,7 @@ namespace Octopus.Server.Extensibility.Authentication.AzureAD.Configuration { - class AzureADConfigurationStore : OpenIdConnectConfigurationWithRoleStore, IAzureADConfigurationStore + class AzureADConfigurationStore : OpenIDConnectConfigurationWithRoleStore, IAzureADConfigurationStore { public const string SingletonId = "authentication-aad"; diff --git a/source/Server.AzureAD/Configuration/AzureADConfigureCommands.cs b/source/Server.AzureAD/Configuration/AzureADConfigureCommands.cs index d16971b..a494459 100644 --- a/source/Server.AzureAD/Configuration/AzureADConfigureCommands.cs +++ b/source/Server.AzureAD/Configuration/AzureADConfigureCommands.cs @@ -7,7 +7,7 @@ namespace Octopus.Server.Extensibility.Authentication.AzureAD.Configuration { - class AzureADConfigureCommands : OpenIdConnectConfigureCommands + class AzureADConfigureCommands : OpenIDConnectConfigureCommands { public AzureADConfigureCommands( ISystemLog log, diff --git a/source/Server.AzureAD/Web/AzureADUserAuthenticatedAction.cs b/source/Server.AzureAD/Web/AzureADUserAuthenticatedAction.cs index f575196..6f9248d 100644 --- a/source/Server.AzureAD/Web/AzureADUserAuthenticatedAction.cs +++ b/source/Server.AzureAD/Web/AzureADUserAuthenticatedAction.cs @@ -16,26 +16,24 @@ public AzureADUserAuthenticatedAction( ISystemLog log, IAzureADAuthTokenHandler authTokenHandler, IAzureADPrincipalToUserResourceMapper principalToUserResourceMapper, - IUpdateableUserStore userStore, IAzureADConfigurationStore configurationStore, IAuthCookieCreator authCookieCreator, IInvalidLoginTracker loginTracker, ISleep sleep, IAzureADIdentityCreator identityCreator, - IClock clock, - IUrlEncoder encoder) : + IUrlEncoder encoder, + IUserService userService) : base( log, authTokenHandler, principalToUserResourceMapper, - userStore, configurationStore, authCookieCreator, loginTracker, sleep, identityCreator, - clock, - encoder) + encoder, + userService) { } diff --git a/source/Server.AzureAD/Web/AzureADUserAuthenticatedPkceAction.cs b/source/Server.AzureAD/Web/AzureADUserAuthenticatedPkceAction.cs new file mode 100644 index 0000000..a8c5ab7 --- /dev/null +++ b/source/Server.AzureAD/Web/AzureADUserAuthenticatedPkceAction.cs @@ -0,0 +1,47 @@ +using Octopus.Diagnostics; +using Octopus.Server.Extensibility.Authentication.AzureAD.Configuration; +using Octopus.Server.Extensibility.Authentication.AzureAD.Identities; +using Octopus.Server.Extensibility.Authentication.AzureAD.Infrastructure; +using Octopus.Server.Extensibility.Authentication.AzureAD.Tokens; +using Octopus.Server.Extensibility.Authentication.HostServices; +using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Issuer; +using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Web; +using Octopus.Server.Extensibility.HostServices.Web; +using Octopus.Server.Extensibility.Mediator; +using Octopus.Time; + +namespace Octopus.Server.Extensibility.Authentication.AzureAD.Web +{ + class AzureADUserAuthenticatedPkceAction : UserAuthenticatedPkceAction + { + public AzureADUserAuthenticatedPkceAction( + ISystemLog log, + IAzureADAuthTokenHandler authTokenHandler, + IAzureADPrincipalToUserResourceMapper principalToUserResourceMapper, + IAzureADConfigurationStore configurationStore, + IAuthCookieCreator authCookieCreator, + IInvalidLoginTracker loginTracker, + ISleep sleep, + IAzureADIdentityCreator identityCreator, + IUrlEncoder encoder, + IIdentityProviderConfigDiscoverer identityProviderConfigDiscoverer, + IMediator mediator, + IUserService service) + : base(log, + authTokenHandler, + principalToUserResourceMapper, + configurationStore, + authCookieCreator, + loginTracker, + sleep, + identityCreator, + encoder, + identityProviderConfigDiscoverer, + mediator, + service) + { + } + + protected override string ProviderName => AzureADAuthenticationProvider.ProviderName; + } +} \ No newline at end of file diff --git a/source/Server.AzureAD/Web/AzureADUserAuthenticationAction.cs b/source/Server.AzureAD/Web/AzureADUserAuthenticationAction.cs index c3e2933..8ce4eb1 100644 --- a/source/Server.AzureAD/Web/AzureADUserAuthenticationAction.cs +++ b/source/Server.AzureAD/Web/AzureADUserAuthenticationAction.cs @@ -5,6 +5,7 @@ using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Issuer; using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Web; using Octopus.Server.Extensibility.Extensions.Infrastructure.Web.Api; +using Octopus.Server.Extensibility.Mediator; namespace Octopus.Server.Extensibility.Authentication.AzureAD.Web { @@ -16,7 +17,8 @@ public AzureADUserAuthenticationAction( IIdentityProviderConfigDiscoverer identityProviderConfigDiscoverer, IAzureADAuthorizationEndpointUrlBuilder urlBuilder, IApiActionModelBinder modelBinder, - IAuthenticationConfigurationStore authenticationConfigurationStore) : base(log, configurationStore, identityProviderConfigDiscoverer, urlBuilder, modelBinder, authenticationConfigurationStore) + IAuthenticationConfigurationStore authenticationConfigurationStore, + IMediator mediator) : base(log, configurationStore, identityProviderConfigDiscoverer, urlBuilder, modelBinder, authenticationConfigurationStore, mediator) { } } diff --git a/source/Server.GoogleApps/Configuration/GoogleAppsConfigurationSettings.cs b/source/Server.GoogleApps/Configuration/GoogleAppsConfigurationSettings.cs index edeaadd..94b0fe0 100644 --- a/source/Server.GoogleApps/Configuration/GoogleAppsConfigurationSettings.cs +++ b/source/Server.GoogleApps/Configuration/GoogleAppsConfigurationSettings.cs @@ -4,7 +4,7 @@ namespace Octopus.Server.Extensibility.Authentication.GoogleApps.Configuration { - class GoogleAppsConfigurationSettings : OpenIdConnectConfigurationSettings, IGoogleAppsConfigurationSettings + class GoogleAppsConfigurationSettings : OpenIDConnectConfigurationSettings, IGoogleAppsConfigurationSettings { public GoogleAppsConfigurationSettings(IGoogleAppsConfigurationStore configurationDocumentStore) : base(configurationDocumentStore) { diff --git a/source/Server.GoogleApps/Configuration/GoogleAppsConfigurationStore.cs b/source/Server.GoogleApps/Configuration/GoogleAppsConfigurationStore.cs index 3dfd0f7..db08fde 100644 --- a/source/Server.GoogleApps/Configuration/GoogleAppsConfigurationStore.cs +++ b/source/Server.GoogleApps/Configuration/GoogleAppsConfigurationStore.cs @@ -3,7 +3,7 @@ namespace Octopus.Server.Extensibility.Authentication.GoogleApps.Configuration { - class GoogleAppsConfigurationStore : OpenIdConnectConfigurationStore, IGoogleAppsConfigurationStore + class GoogleAppsConfigurationStore : OpenIDConnectConfigurationStore, IGoogleAppsConfigurationStore { public const string SingletonId = "authentication-googleapps"; diff --git a/source/Server.GoogleApps/Configuration/GoogleAppsConfigureCommands.cs b/source/Server.GoogleApps/Configuration/GoogleAppsConfigureCommands.cs index 2ec9c13..a061948 100644 --- a/source/Server.GoogleApps/Configuration/GoogleAppsConfigureCommands.cs +++ b/source/Server.GoogleApps/Configuration/GoogleAppsConfigureCommands.cs @@ -7,7 +7,7 @@ namespace Octopus.Server.Extensibility.Authentication.GoogleApps.Configuration { - class GoogleAppsConfigureCommands : OpenIdConnectConfigureCommands + class GoogleAppsConfigureCommands : OpenIDConnectConfigureCommands { public GoogleAppsConfigureCommands( ISystemLog log, diff --git a/source/Server.GoogleApps/GoogleAppsApi.cs b/source/Server.GoogleApps/GoogleAppsApi.cs index 5b54bef..28368e0 100644 --- a/source/Server.GoogleApps/GoogleAppsApi.cs +++ b/source/Server.GoogleApps/GoogleAppsApi.cs @@ -16,6 +16,7 @@ public GoogleAppsApi( { Add("POST", authenticationProvider.AuthenticateUri, RouteCategory.Raw, new AnonymousWhenEnabledEndpointInvocation(), null, "OpenIDConnect"); Add("POST", configurationStore.RedirectUri, RouteCategory.Raw, new AnonymousWhenEnabledEndpointInvocation(), null, "OpenIDConnect"); + Add("GET", configurationStore.RedirectUri, RouteCategory.Raw, new AnonymousWhenEnabledEndpointInvocation(), null, "OpenIDConnect"); } } } \ No newline at end of file diff --git a/source/Server.GoogleApps/GoogleAppsExtension.cs b/source/Server.GoogleApps/GoogleAppsExtension.cs index 8c1cbd1..a6c64e0 100644 --- a/source/Server.GoogleApps/GoogleAppsExtension.cs +++ b/source/Server.GoogleApps/GoogleAppsExtension.cs @@ -58,6 +58,7 @@ public override void Load(ContainerBuilder builder) builder.RegisterType().As().SingleInstance(); builder.RegisterType().AsSelf().InstancePerDependency(); + builder.RegisterType().AsSelf().InstancePerDependency(); builder.RegisterType().AsSelf().InstancePerDependency(); builder.RegisterType() diff --git a/source/Server.GoogleApps/Issuer/GoogleAppsAuthorizationEndpointUrlBuilder.cs b/source/Server.GoogleApps/Issuer/GoogleAppsAuthorizationEndpointUrlBuilder.cs index cbca793..ad14329 100644 --- a/source/Server.GoogleApps/Issuer/GoogleAppsAuthorizationEndpointUrlBuilder.cs +++ b/source/Server.GoogleApps/Issuer/GoogleAppsAuthorizationEndpointUrlBuilder.cs @@ -11,9 +11,9 @@ public GoogleAppsAuthorizationEndpointUrlBuilder(IGoogleAppsConfigurationStore c { } - public override string Build(string requestDirectoryPath, IssuerConfiguration issuerConfiguration, string nonce, string? state = null) + public override string Build(string requestDirectoryPath, IssuerConfiguration issuerConfiguration, string? nonce = null, string? state = null, string? codeChallenge = null) { - var url = base.Build(requestDirectoryPath, issuerConfiguration, nonce, state); + var url = base.Build(requestDirectoryPath, issuerConfiguration, nonce, state, codeChallenge); var hd = ConfigurationStore.GetHostedDomain(); if (!string.IsNullOrWhiteSpace(hd)) diff --git a/source/Server.GoogleApps/Web/GoogleAppsUserAuthenticatedAction.cs b/source/Server.GoogleApps/Web/GoogleAppsUserAuthenticatedAction.cs index a0b0a0e..0707a5e 100644 --- a/source/Server.GoogleApps/Web/GoogleAppsUserAuthenticatedAction.cs +++ b/source/Server.GoogleApps/Web/GoogleAppsUserAuthenticatedAction.cs @@ -17,14 +17,24 @@ public GoogleAppsUserAuthenticatedAction( ISystemLog log, IGoogleAuthTokenHandler authTokenHandler, IPrincipalToUserResourceMapper principalToUserResourceMapper, - IUpdateableUserStore userStore, IGoogleAppsConfigurationStore configurationStore, IAuthCookieCreator authCookieCreator, IInvalidLoginTracker loginTracker, ISleep sleep, IGoogleAppsIdentityCreator identityCreator, - IClock clock, IUrlEncoder encoder) - : base(log, authTokenHandler, principalToUserResourceMapper, userStore, configurationStore, authCookieCreator, loginTracker, sleep, identityCreator, clock, encoder) + IUrlEncoder encoder, + IUserService userService) : + base( + log, + authTokenHandler, + principalToUserResourceMapper, + configurationStore, + authCookieCreator, + loginTracker, + sleep, + identityCreator, + encoder, + userService) { } diff --git a/source/Server.GoogleApps/Web/GoogleAppsUserAuthenticatedPkceAction.cs b/source/Server.GoogleApps/Web/GoogleAppsUserAuthenticatedPkceAction.cs new file mode 100644 index 0000000..71cf361 --- /dev/null +++ b/source/Server.GoogleApps/Web/GoogleAppsUserAuthenticatedPkceAction.cs @@ -0,0 +1,48 @@ +using Octopus.Diagnostics; +using Octopus.Server.Extensibility.Authentication.GoogleApps.Configuration; +using Octopus.Server.Extensibility.Authentication.GoogleApps.Identities; +using Octopus.Server.Extensibility.Authentication.GoogleApps.Tokens; +using Octopus.Server.Extensibility.Authentication.HostServices; +using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Infrastructure; +using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Issuer; +using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Web; +using Octopus.Server.Extensibility.HostServices.Web; +using Octopus.Server.Extensibility.Mediator; +using Octopus.Time; + +namespace Octopus.Server.Extensibility.Authentication.GoogleApps.Web +{ + class GoogleAppsUserAuthenticatedPkceAction + : UserAuthenticatedPkceAction + { + public GoogleAppsUserAuthenticatedPkceAction( + ISystemLog log, + IGoogleAuthTokenHandler authTokenHandler, + IPrincipalToUserResourceMapper principalToUserResourceMapper, + IGoogleAppsConfigurationStore configurationStore, + IAuthCookieCreator authCookieCreator, + IInvalidLoginTracker loginTracker, + ISleep sleep, + IGoogleAppsIdentityCreator identityCreator, + IUrlEncoder encoder, + IIdentityProviderConfigDiscoverer identityProviderConfigDiscoverer, + IMediator mediator, + IUserService service) + : base(log, + authTokenHandler, + principalToUserResourceMapper, + configurationStore, + authCookieCreator, + loginTracker, + sleep, + identityCreator, + encoder, + identityProviderConfigDiscoverer, + mediator, + service) + { + } + + protected override string ProviderName => GoogleAppsAuthenticationProvider.ProviderName; + } +} \ No newline at end of file diff --git a/source/Server.GoogleApps/Web/GoogleAppsUserAuthenticationAction.cs b/source/Server.GoogleApps/Web/GoogleAppsUserAuthenticationAction.cs index b69a5c5..b1d99f0 100644 --- a/source/Server.GoogleApps/Web/GoogleAppsUserAuthenticationAction.cs +++ b/source/Server.GoogleApps/Web/GoogleAppsUserAuthenticationAction.cs @@ -5,6 +5,7 @@ using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Issuer; using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Web; using Octopus.Server.Extensibility.Extensions.Infrastructure.Web.Api; +using Octopus.Server.Extensibility.Mediator; namespace Octopus.Server.Extensibility.Authentication.GoogleApps.Web { @@ -16,8 +17,9 @@ public GoogleAppsUserAuthenticationAction( IIdentityProviderConfigDiscoverer identityProviderConfigDiscoverer, IGoogleAppsAuthorizationEndpointUrlBuilder urlBuilder, IApiActionModelBinder modelBinder, - IAuthenticationConfigurationStore authenticationConfigurationStore) - : base(log, configurationStore, identityProviderConfigDiscoverer, urlBuilder, modelBinder, authenticationConfigurationStore) + IAuthenticationConfigurationStore authenticationConfigurationStore, + IMediator mediator) + : base(log, configurationStore, identityProviderConfigDiscoverer, urlBuilder, modelBinder, authenticationConfigurationStore, mediator) { } } diff --git a/source/Server.OctopusID/Configuration/IOctopusIDConfigurationStore.cs b/source/Server.OctopusID/Configuration/IOctopusIDConfigurationStore.cs index 4b33730..89121ee 100644 --- a/source/Server.OctopusID/Configuration/IOctopusIDConfigurationStore.cs +++ b/source/Server.OctopusID/Configuration/IOctopusIDConfigurationStore.cs @@ -2,8 +2,7 @@ namespace Octopus.Server.Extensibility.Authentication.OctopusID.Configuration { - interface IOctopusIDConfigurationStore : IOpenIDConnectWithClientSecretConfigurationStore, - IOpenIDConnectConfigurationWithRoleStore + interface IOctopusIDConfigurationStore : IOpenIDConnectConfigurationWithRoleStore { } } \ No newline at end of file diff --git a/source/Server.OctopusID/Configuration/OctopusIDConfiguration.cs b/source/Server.OctopusID/Configuration/OctopusIDConfiguration.cs index fce2918..16214a9 100644 --- a/source/Server.OctopusID/Configuration/OctopusIDConfiguration.cs +++ b/source/Server.OctopusID/Configuration/OctopusIDConfiguration.cs @@ -2,7 +2,7 @@ namespace Octopus.Server.Extensibility.Authentication.OctopusID.Configuration { - class OctopusIDConfiguration : OpenIDConnectConfigurationWithClientSecret, IOpenIDConnectConfigurationWithRole + class OctopusIDConfiguration : OpenIDConnectConfiguration, IOpenIDConnectConfigurationWithRole { public static string DefaultRoleClaimType = "roles"; diff --git a/source/Server.OctopusID/Configuration/OctopusIDConfigurationResource.cs b/source/Server.OctopusID/Configuration/OctopusIDConfigurationResource.cs index 82a4739..ff2b0bb 100644 --- a/source/Server.OctopusID/Configuration/OctopusIDConfigurationResource.cs +++ b/source/Server.OctopusID/Configuration/OctopusIDConfigurationResource.cs @@ -6,7 +6,7 @@ namespace Octopus.Server.Extensibility.Authentication.OctopusID.Configuration { [Description("Sign in to your Octopus Server with an Octopus ID. [Learn more](https://g.octopushq.com/AuthOctopusID).")] - class OctopusIDConfigurationResource : OpenIDConnectConfigurationWithClientSecretResource + class OctopusIDConfigurationResource : OpenIDConnectConfigurationResource { [Writeable] public override string? Issuer { get; set; } diff --git a/source/Server.OctopusID/Configuration/OctopusIDConfigurationSettings.cs b/source/Server.OctopusID/Configuration/OctopusIDConfigurationSettings.cs index 4ffa024..b54504a 100644 --- a/source/Server.OctopusID/Configuration/OctopusIDConfigurationSettings.cs +++ b/source/Server.OctopusID/Configuration/OctopusIDConfigurationSettings.cs @@ -5,7 +5,7 @@ namespace Octopus.Server.Extensibility.Authentication.OctopusID.Configuration { - class OctopusIDConfigurationSettings : OpenIdConnectConfigurationSettings, IOctopusIDConfigurationSettings + class OctopusIDConfigurationSettings : OpenIDConnectConfigurationSettings, IOctopusIDConfigurationSettings { public OctopusIDConfigurationSettings(IOctopusIDConfigurationStore configurationDocumentStore) : base(configurationDocumentStore) { diff --git a/source/Server.OctopusID/Configuration/OctopusIDConfigurationStore.cs b/source/Server.OctopusID/Configuration/OctopusIDConfigurationStore.cs index d2e211a..a2a3cfc 100644 --- a/source/Server.OctopusID/Configuration/OctopusIDConfigurationStore.cs +++ b/source/Server.OctopusID/Configuration/OctopusIDConfigurationStore.cs @@ -3,7 +3,7 @@ namespace Octopus.Server.Extensibility.Authentication.OctopusID.Configuration { - class OctopusIDConfigurationStore : OpenIDConnectWithClientSecretConfigurationStore, IOctopusIDConfigurationStore + class OctopusIDConfigurationStore : OpenIDConnectConfigurationStore, IOctopusIDConfigurationStore { public const string SingletonId = "authentication-octopusid"; diff --git a/source/Server.OctopusID/Configuration/OctopusIDConfigureCommands.cs b/source/Server.OctopusID/Configuration/OctopusIDConfigureCommands.cs index 2cd406c..0a7e8b8 100644 --- a/source/Server.OctopusID/Configuration/OctopusIDConfigureCommands.cs +++ b/source/Server.OctopusID/Configuration/OctopusIDConfigureCommands.cs @@ -8,7 +8,7 @@ namespace Octopus.Server.Extensibility.Authentication.OctopusID.Configuration { - class OctopusIDConfigureCommands : OpenIdConnectConfigureCommands + class OctopusIDConfigureCommands : OpenIDConnectConfigureCommands { public OctopusIDConfigureCommands( ISystemLog log, @@ -26,11 +26,6 @@ public override IEnumerable GetOptions() { yield return option; } - yield return new ConfigureCommandOption($"{ConfigurationSettingsName}ClientSecret=", "Tell Octopus the shared secret to use for Octopus ID authentication requests.", v => - { - ConfigurationStore.Value.SetClientSecret(v.ToSensitiveString()); - Log.Info($"{ConfigurationSettingsName} ClientSecret set"); - }, hide: true); } } } \ No newline at end of file diff --git a/source/Server.OctopusID/OctopusIDApi.cs b/source/Server.OctopusID/OctopusIDApi.cs index 16e2ac0..be10818 100644 --- a/source/Server.OctopusID/OctopusIDApi.cs +++ b/source/Server.OctopusID/OctopusIDApi.cs @@ -16,6 +16,7 @@ public OctopusIDApi( { Add("POST", authenticationProvider.AuthenticateUri, RouteCategory.Raw, new AnonymousWhenEnabledEndpointInvocation(), null, "OpenIDConnect"); Add("POST", configurationStore.RedirectUri, RouteCategory.Raw, new AnonymousWhenEnabledEndpointInvocation(), null, "OpenIDConnect"); + Add("GET", configurationStore.RedirectUri, RouteCategory.Raw, new AnonymousWhenEnabledEndpointInvocation(), null, "OpenIDConnect"); } } } \ No newline at end of file diff --git a/source/Server.OctopusID/OctopusIDExtension.cs b/source/Server.OctopusID/OctopusIDExtension.cs index 34a6086..dadc5eb 100644 --- a/source/Server.OctopusID/OctopusIDExtension.cs +++ b/source/Server.OctopusID/OctopusIDExtension.cs @@ -61,6 +61,7 @@ public override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerDependency(); builder.RegisterType().AsSelf().InstancePerDependency(); + builder.RegisterType().AsSelf().InstancePerDependency(); builder.RegisterType().AsSelf().InstancePerDependency(); builder.RegisterType() diff --git a/source/Server.OctopusID/Web/OctopusIDUserAuthenticatedAction.cs b/source/Server.OctopusID/Web/OctopusIDUserAuthenticatedAction.cs index a0945af..f085484 100644 --- a/source/Server.OctopusID/Web/OctopusIDUserAuthenticatedAction.cs +++ b/source/Server.OctopusID/Web/OctopusIDUserAuthenticatedAction.cs @@ -16,25 +16,24 @@ public OctopusIDUserAuthenticatedAction( ISystemLog log, IOctopusIDAuthTokenHandler authTokenHandler, IOctopusIDPrincipalToUserResourceMapper principalToUserResourceMapper, - IUpdateableUserStore userStore, IOctopusIDConfigurationStore configurationStore, IAuthCookieCreator authCookieCreator, IInvalidLoginTracker loginTracker, ISleep sleep, IOctopusIDIdentityCreator identityCreator, - IClock clock, IUrlEncoder encoder) : + IUrlEncoder encoder, + IUserService userService) : base( log, authTokenHandler, principalToUserResourceMapper, - userStore, configurationStore, authCookieCreator, loginTracker, sleep, identityCreator, - clock, - encoder) + encoder, + userService) { } diff --git a/source/Server.OctopusID/Web/OctopusIDUserAuthenticatedPkceAction.cs b/source/Server.OctopusID/Web/OctopusIDUserAuthenticatedPkceAction.cs new file mode 100644 index 0000000..9a3d218 --- /dev/null +++ b/source/Server.OctopusID/Web/OctopusIDUserAuthenticatedPkceAction.cs @@ -0,0 +1,48 @@ +using Octopus.Diagnostics; +using Octopus.Server.Extensibility.Authentication.HostServices; +using Octopus.Server.Extensibility.Authentication.OctopusID.Configuration; +using Octopus.Server.Extensibility.Authentication.OctopusID.Identities; +using Octopus.Server.Extensibility.Authentication.OctopusID.Tokens; +using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Infrastructure; +using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Issuer; +using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Web; +using Octopus.Server.Extensibility.HostServices.Web; +using Octopus.Server.Extensibility.Mediator; +using Octopus.Time; + +namespace Octopus.Server.Extensibility.Authentication.OctopusID.Web +{ + class OctopusIDUserAuthenticatedPkceAction + : UserAuthenticatedPkceAction + { + public OctopusIDUserAuthenticatedPkceAction( + ISystemLog log, + IOctopusIDAuthTokenHandler authTokenHandler, + IPrincipalToUserResourceMapper principalToUserResourceMapper, + IOctopusIDConfigurationStore configurationStore, + IAuthCookieCreator authCookieCreator, + IInvalidLoginTracker loginTracker, + ISleep sleep, + IOctopusIDIdentityCreator identityCreator, + IUrlEncoder encoder, + IIdentityProviderConfigDiscoverer identityProviderConfigDiscoverer, + IMediator mediator, + IUserService service) + : base(log, + authTokenHandler, + principalToUserResourceMapper, + configurationStore, + authCookieCreator, + loginTracker, + sleep, + identityCreator, + encoder, + identityProviderConfigDiscoverer, + mediator, + service) + { + } + + protected override string ProviderName => OctopusIDAuthenticationProvider.ProviderName; + } +} \ No newline at end of file diff --git a/source/Server.OctopusID/Web/OctopusIDUserAuthenticationAction.cs b/source/Server.OctopusID/Web/OctopusIDUserAuthenticationAction.cs index f010cbe..dc21594 100644 --- a/source/Server.OctopusID/Web/OctopusIDUserAuthenticationAction.cs +++ b/source/Server.OctopusID/Web/OctopusIDUserAuthenticationAction.cs @@ -4,6 +4,7 @@ using Octopus.Server.Extensibility.Authentication.OctopusID.Issuer; using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Web; using Octopus.Server.Extensibility.Extensions.Infrastructure.Web.Api; +using Octopus.Server.Extensibility.Mediator; namespace Octopus.Server.Extensibility.Authentication.OctopusID.Web { @@ -15,7 +16,8 @@ public OctopusIDUserAuthenticationAction( IOctopusIdentityProviderConfigDiscoverer identityProviderConfigDiscoverer, IOctopusIDAuthorizationEndpointUrlBuilder urlBuilder, IApiActionModelBinder modelBinder, - IAuthenticationConfigurationStore authenticationConfigurationStore) : base(log, configurationStore, identityProviderConfigDiscoverer, urlBuilder, modelBinder, authenticationConfigurationStore) + IAuthenticationConfigurationStore authenticationConfigurationStore, + IMediator mediator) : base(log, configurationStore, identityProviderConfigDiscoverer, urlBuilder, modelBinder, authenticationConfigurationStore, mediator) { } } diff --git a/source/Server.Okta/Configuration/OktaConfigurationSettings.cs b/source/Server.Okta/Configuration/OktaConfigurationSettings.cs index cb341d5..74b0c80 100644 --- a/source/Server.Okta/Configuration/OktaConfigurationSettings.cs +++ b/source/Server.Okta/Configuration/OktaConfigurationSettings.cs @@ -4,7 +4,7 @@ namespace Octopus.Server.Extensibility.Authentication.Okta.Configuration { - class OktaConfigurationSettings : OpenIdConnectConfigurationSettings, IOktaConfigurationSettings + class OktaConfigurationSettings : OpenIDConnectConfigurationSettings, IOktaConfigurationSettings { public OktaConfigurationSettings(IOktaConfigurationStore configurationDocumentStore) : base(configurationDocumentStore) { diff --git a/source/Server.Okta/Configuration/OktaConfigurationStore.cs b/source/Server.Okta/Configuration/OktaConfigurationStore.cs index 4253391..1667150 100644 --- a/source/Server.Okta/Configuration/OktaConfigurationStore.cs +++ b/source/Server.Okta/Configuration/OktaConfigurationStore.cs @@ -3,7 +3,7 @@ namespace Octopus.Server.Extensibility.Authentication.Okta.Configuration { - class OktaConfigurationStore : OpenIdConnectConfigurationWithRoleStore, IOktaConfigurationStore + class OktaConfigurationStore : OpenIDConnectConfigurationWithRoleStore, IOktaConfigurationStore { public const string SingletonId = "authentication-od"; diff --git a/source/Server.Okta/Configuration/OktaConfigureCommands.cs b/source/Server.Okta/Configuration/OktaConfigureCommands.cs index 7858d20..2f9e9dc 100644 --- a/source/Server.Okta/Configuration/OktaConfigureCommands.cs +++ b/source/Server.Okta/Configuration/OktaConfigureCommands.cs @@ -7,7 +7,7 @@ namespace Octopus.Server.Extensibility.Authentication.Okta.Configuration { - class OktaConfigureCommands : OpenIdConnectConfigureCommands + class OktaConfigureCommands : OpenIDConnectConfigureCommands { public OktaConfigureCommands( ISystemLog log, diff --git a/source/Server.Okta/OktaApi.cs b/source/Server.Okta/OktaApi.cs index 83b7392..33830b8 100644 --- a/source/Server.Okta/OktaApi.cs +++ b/source/Server.Okta/OktaApi.cs @@ -1,9 +1,7 @@ -using System; -using Octopus.Server.Extensibility.Authentication.Okta.Configuration; +using Octopus.Server.Extensibility.Authentication.Okta.Configuration; using Octopus.Server.Extensibility.Authentication.Okta.Identities; using Octopus.Server.Extensibility.Authentication.Okta.Tokens; using Octopus.Server.Extensibility.Authentication.Okta.Web; -using Octopus.Server.Extensibility.Authentication.OpenIDConnect; using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common; using Octopus.Server.Extensibility.Extensions.Infrastructure.Web.Api; @@ -16,6 +14,7 @@ public OktaApi(IOktaConfigurationStore configurationStore, OktaAuthenticationPro { Add("POST", authenticationProvider.AuthenticateUri, RouteCategory.Raw, new AnonymousWhenEnabledEndpointInvocation(), null, "OpenIDConnect"); Add("POST", configurationStore.RedirectUri, RouteCategory.Raw, new AnonymousWhenEnabledEndpointInvocation(), null, "OpenIDConnect"); + Add("GET", configurationStore.RedirectUri, RouteCategory.Raw, new AnonymousWhenEnabledEndpointInvocation(), null, "OpenIDConnect"); } } } \ No newline at end of file diff --git a/source/Server.Okta/OktaExtension.cs b/source/Server.Okta/OktaExtension.cs index 17f0f93..0c3c1b8 100644 --- a/source/Server.Okta/OktaExtension.cs +++ b/source/Server.Okta/OktaExtension.cs @@ -62,6 +62,7 @@ public override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerDependency(); builder.RegisterType().AsSelf().InstancePerDependency(); + builder.RegisterType().AsSelf().InstancePerDependency(); builder.RegisterType().AsSelf().InstancePerDependency(); builder.RegisterType() diff --git a/source/Server.Okta/Web/OktaUserAuthenticatedAction.cs b/source/Server.Okta/Web/OktaUserAuthenticatedAction.cs index fa87682..5872ee4 100644 --- a/source/Server.Okta/Web/OktaUserAuthenticatedAction.cs +++ b/source/Server.Okta/Web/OktaUserAuthenticatedAction.cs @@ -17,25 +17,24 @@ public OktaUserAuthenticatedAction( ISystemLog log, IOktaAuthTokenHandler authTokenHandler, IOktaPrincipalToUserResourceMapper principalToUserResourceMapper, - IUpdateableUserStore userStore, IOktaConfigurationStore configurationStore, IAuthCookieCreator authCookieCreator, IInvalidLoginTracker loginTracker, ISleep sleep, IOktaIdentityCreator identityCreator, - IClock clock, IUrlEncoder encoder) : + IUrlEncoder encoder, + IUserService userService) : base( log, authTokenHandler, principalToUserResourceMapper, - userStore, configurationStore, authCookieCreator, loginTracker, sleep, identityCreator, - clock, - encoder) + encoder, + userService) { } diff --git a/source/Server.Okta/Web/OktaUserAuthenticatedPkceAction.cs b/source/Server.Okta/Web/OktaUserAuthenticatedPkceAction.cs new file mode 100644 index 0000000..813af03 --- /dev/null +++ b/source/Server.Okta/Web/OktaUserAuthenticatedPkceAction.cs @@ -0,0 +1,48 @@ +using Octopus.Diagnostics; +using Octopus.Server.Extensibility.Authentication.HostServices; +using Octopus.Server.Extensibility.Authentication.Okta.Configuration; +using Octopus.Server.Extensibility.Authentication.Okta.Identities; +using Octopus.Server.Extensibility.Authentication.Okta.Tokens; +using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Infrastructure; +using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Issuer; +using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Web; +using Octopus.Server.Extensibility.HostServices.Web; +using Octopus.Server.Extensibility.Mediator; +using Octopus.Time; + +namespace Octopus.Server.Extensibility.Authentication.Okta.Web +{ + class OktaUserAuthenticatedPkceAction + : UserAuthenticatedPkceAction + { + public OktaUserAuthenticatedPkceAction( + ISystemLog log, + IOktaAuthTokenHandler authTokenHandler, + IPrincipalToUserResourceMapper principalToUserResourceMapper, + IOktaConfigurationStore configurationStore, + IAuthCookieCreator authCookieCreator, + IInvalidLoginTracker loginTracker, + ISleep sleep, + IOktaIdentityCreator identityCreator, + IUrlEncoder encoder, + IIdentityProviderConfigDiscoverer identityProviderConfigDiscoverer, + IMediator mediator, + IUserService service) + : base(log, + authTokenHandler, + principalToUserResourceMapper, + configurationStore, + authCookieCreator, + loginTracker, + sleep, + identityCreator, + encoder, + identityProviderConfigDiscoverer, + mediator, + service) + { + } + + protected override string ProviderName => OktaAuthenticationProvider.ProviderName; + } +} \ No newline at end of file diff --git a/source/Server.Okta/Web/OktaUserAuthenticationAction.cs b/source/Server.Okta/Web/OktaUserAuthenticationAction.cs index 7b5631b..2d980e9 100644 --- a/source/Server.Okta/Web/OktaUserAuthenticationAction.cs +++ b/source/Server.Okta/Web/OktaUserAuthenticationAction.cs @@ -5,6 +5,7 @@ using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Issuer; using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Web; using Octopus.Server.Extensibility.Extensions.Infrastructure.Web.Api; +using Octopus.Server.Extensibility.Mediator; namespace Octopus.Server.Extensibility.Authentication.Okta.Web { @@ -16,7 +17,8 @@ public OktaUserAuthenticationAction( IIdentityProviderConfigDiscoverer identityProviderConfigDiscoverer, IOktaAuthorizationEndpointUrlBuilder urlBuilder, IApiActionModelBinder modelBinder, - IAuthenticationConfigurationStore authenticationConfigurationStore) : base(log, configurationStore, identityProviderConfigDiscoverer, urlBuilder, modelBinder, authenticationConfigurationStore) + IAuthenticationConfigurationStore authenticationConfigurationStore, + IMediator mediator) : base(log, configurationStore, identityProviderConfigDiscoverer, urlBuilder, modelBinder, authenticationConfigurationStore, mediator) { } } diff --git a/source/Server.OpenIDConnect.Common/Configuration/IOpenIDConnectConfigurationStore.cs b/source/Server.OpenIDConnect.Common/Configuration/IOpenIDConnectConfigurationStore.cs index caac390..87b5289 100644 --- a/source/Server.OpenIDConnect.Common/Configuration/IOpenIDConnectConfigurationStore.cs +++ b/source/Server.OpenIDConnect.Common/Configuration/IOpenIDConnectConfigurationStore.cs @@ -18,6 +18,10 @@ public interface IOpenIDConnectConfigurationStore : IExtensionConfigurationStore string? GetClientId(); void SetClientId(string? clientId); + SensitiveString? GetClientSecret(); + void SetClientSecret(SensitiveString? clientSecret); + bool HasClientSecret { get; } + string? GetScope(); void SetScope(string? scope); diff --git a/source/Server.OpenIDConnect.Common/Configuration/IOpenIDConnectConfigurationWithClientSecret.cs b/source/Server.OpenIDConnect.Common/Configuration/IOpenIDConnectConfigurationWithClientSecret.cs deleted file mode 100644 index 4bd96d7..0000000 --- a/source/Server.OpenIDConnect.Common/Configuration/IOpenIDConnectConfigurationWithClientSecret.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Octopus.Data.Model; - -namespace Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Configuration -{ - public interface IOpenIDConnectConfigurationWithClientSecret : IOpenIDConnectConfiguration - { - SensitiveString? ClientSecret { get; set; } - } -} \ No newline at end of file diff --git a/source/Server.OpenIDConnect.Common/Configuration/IOpenIDConnectWithClientSecretConfigurationStore.cs b/source/Server.OpenIDConnect.Common/Configuration/IOpenIDConnectWithClientSecretConfigurationStore.cs deleted file mode 100644 index 4b79213..0000000 --- a/source/Server.OpenIDConnect.Common/Configuration/IOpenIDConnectWithClientSecretConfigurationStore.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Octopus.Data.Model; - -namespace Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Configuration -{ - public interface IOpenIDConnectWithClientSecretConfigurationStore : IOpenIDConnectConfigurationStore, IOpenIDConnectWithClientSecretConfigurationStore - where TConfiguration : OpenIDConnectConfiguration, IId, new() - { - } - - public interface IOpenIDConnectWithClientSecretConfigurationStore : IOpenIDConnectConfigurationStore - { - SensitiveString? GetClientSecret(); - void SetClientSecret(SensitiveString? clientSecret); - } -} \ No newline at end of file diff --git a/source/Server.OpenIDConnect.Common/Configuration/OpenIDConnectConfiguration.cs b/source/Server.OpenIDConnect.Common/Configuration/OpenIDConnectConfiguration.cs index d3dce86..d974a1a 100644 --- a/source/Server.OpenIDConnect.Common/Configuration/OpenIDConnectConfiguration.cs +++ b/source/Server.OpenIDConnect.Common/Configuration/OpenIDConnectConfiguration.cs @@ -1,13 +1,17 @@ -using Octopus.Server.Extensibility.Extensions.Infrastructure.Configuration; +using System; +using Octopus.Data.Model; +using Octopus.Server.Extensibility.Extensions.Infrastructure.Configuration; namespace Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Configuration { public abstract class OpenIDConnectConfiguration : ExtensionConfigurationDocument, IOpenIDConnectConfiguration { - public const string DefaultResponseType = "code+id_token"; + public const string AuthCodeResponseType = "code"; + public const string HybridResponseType = "code+id_token"; public const string DefaultResponseMode = "form_post"; public const string DefaultScope = "openid%20profile%20email"; public const string DefaultNameClaimType = "name"; + public const string AuthCodeGrantType = "authorization_code"; protected OpenIDConnectConfiguration(string id) : base(id) { @@ -27,6 +31,8 @@ protected OpenIDConnectConfiguration(string id, string name, string author, stri public string? ClientId { get; set; } + public SensitiveString? ClientSecret { get; set; } + public string? Scope { get; set; } public string? NameClaimType { get; set; } diff --git a/source/Server.OpenIDConnect.Common/Configuration/OpenIDConnectConfigurationResource.cs b/source/Server.OpenIDConnect.Common/Configuration/OpenIDConnectConfigurationResource.cs index 9b5120d..647d0b3 100644 --- a/source/Server.OpenIDConnect.Common/Configuration/OpenIDConnectConfigurationResource.cs +++ b/source/Server.OpenIDConnect.Common/Configuration/OpenIDConnectConfigurationResource.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using Octopus.Server.Extensibility.Extensions.Infrastructure.Configuration; +using Octopus.Server.MessageContracts; using Octopus.Server.MessageContracts.Attributes; namespace Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Configuration @@ -15,6 +16,11 @@ public class OpenIDConnectConfigurationResource : ExtensionConfigurationResource [Writeable] public virtual string? ClientId { get; set; } + [DisplayName("Client Secret")] + [Description("Shared secret for validating the authentication tokens")] + [Writeable] + public virtual SensitiveValue? ClientSecret { get; set; } + [DisplayName("Allow Auto User Creation")] [Description("Tell Octopus to automatically create a user account when a person signs in for the first time with this identity provider")] [Writeable] diff --git a/source/Server.OpenIDConnect.Common/Configuration/OpenIdConnectConfigurationSettings.cs b/source/Server.OpenIDConnect.Common/Configuration/OpenIDConnectConfigurationSettings.cs similarity index 95% rename from source/Server.OpenIDConnect.Common/Configuration/OpenIdConnectConfigurationSettings.cs rename to source/Server.OpenIDConnect.Common/Configuration/OpenIDConnectConfigurationSettings.cs index fbe23fd..4cc6c26 100644 --- a/source/Server.OpenIDConnect.Common/Configuration/OpenIdConnectConfigurationSettings.cs +++ b/source/Server.OpenIDConnect.Common/Configuration/OpenIDConnectConfigurationSettings.cs @@ -5,12 +5,12 @@ namespace Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Configuration { - public abstract class OpenIdConnectConfigurationSettings : ExtensionConfigurationSettings + public abstract class OpenIDConnectConfigurationSettings : ExtensionConfigurationSettings where TConfiguration : OpenIDConnectConfiguration, new() where TResource : ExtensionConfigurationResource where TDocumentStore : IOpenIDConnectConfigurationStore { - protected OpenIdConnectConfigurationSettings(TDocumentStore configurationDocumentStore) : base(configurationDocumentStore) + protected OpenIDConnectConfigurationSettings(TDocumentStore configurationDocumentStore) : base(configurationDocumentStore) { } diff --git a/source/Server.OpenIDConnect.Common/Configuration/OpenIDConnectConfigurationStore.cs b/source/Server.OpenIDConnect.Common/Configuration/OpenIDConnectConfigurationStore.cs index 1087aa0..b8d1fa5 100644 --- a/source/Server.OpenIDConnect.Common/Configuration/OpenIDConnectConfigurationStore.cs +++ b/source/Server.OpenIDConnect.Common/Configuration/OpenIDConnectConfigurationStore.cs @@ -5,12 +5,12 @@ namespace Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Configuration { - public abstract class OpenIdConnectConfigurationStore : ExtensionConfigurationStore, IOpenIDConnectConfigurationStore + public abstract class OpenIDConnectConfigurationStore : ExtensionConfigurationStore, IOpenIDConnectConfigurationStore where TConfiguration : OpenIDConnectConfiguration, IId, new() { public abstract string ConfigurationSettingsName { get; } - protected OpenIdConnectConfigurationStore( + protected OpenIDConnectConfigurationStore( IConfigurationStore configurationStore) : base(configurationStore) { } @@ -40,6 +40,18 @@ public void SetClientId(string? clientId) SetProperty(doc => doc.ClientId = clientId); } + public SensitiveString? GetClientSecret() + { + return GetProperty(doc => doc.ClientSecret); + } + + public void SetClientSecret(SensitiveString? clientSecret) + { + SetProperty(doc => doc.ClientSecret = clientSecret); + } + + public bool HasClientSecret => GetClientSecret() != null; + public string? GetScope() { return GetProperty(doc => doc.Scope); diff --git a/source/Server.OpenIDConnect.Common/Configuration/OpenIDConnectConfigurationWithClientSecret.cs b/source/Server.OpenIDConnect.Common/Configuration/OpenIDConnectConfigurationWithClientSecret.cs deleted file mode 100644 index 51ff55c..0000000 --- a/source/Server.OpenIDConnect.Common/Configuration/OpenIDConnectConfigurationWithClientSecret.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Octopus.Data.Model; - -namespace Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Configuration -{ - public abstract class OpenIDConnectConfigurationWithClientSecret : OpenIDConnectConfiguration, IOpenIDConnectConfigurationWithClientSecret - { - protected OpenIDConnectConfigurationWithClientSecret(string id) : base(id) - { - } - - protected OpenIDConnectConfigurationWithClientSecret(string id, string name, string author, string configurationSchemaVersion) : base(id, name, author, configurationSchemaVersion) - { - } - - public SensitiveString? ClientSecret { get; set; } - } -} \ No newline at end of file diff --git a/source/Server.OpenIDConnect.Common/Configuration/OpenIDConnectConfigurationWithClientSecretResource.cs b/source/Server.OpenIDConnect.Common/Configuration/OpenIDConnectConfigurationWithClientSecretResource.cs deleted file mode 100644 index 5f53df3..0000000 --- a/source/Server.OpenIDConnect.Common/Configuration/OpenIDConnectConfigurationWithClientSecretResource.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.ComponentModel; -using Octopus.Server.MessageContracts; -using Octopus.Server.MessageContracts.Attributes; - -namespace Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Configuration -{ - public class OpenIDConnectConfigurationWithClientSecretResource : OpenIDConnectConfigurationResource - { - [DisplayName("Client Secret")] - [Description("Shared secret for validating the authentication tokens")] - [Writeable] - public virtual SensitiveValue? ClientSecret { get; set; } - } -} \ No newline at end of file diff --git a/source/Server.OpenIDConnect.Common/Configuration/OpenIDConnectConfigurationWithRoleStore.cs b/source/Server.OpenIDConnect.Common/Configuration/OpenIDConnectConfigurationWithRoleStore.cs index 820cd57..1eded21 100644 --- a/source/Server.OpenIDConnect.Common/Configuration/OpenIDConnectConfigurationWithRoleStore.cs +++ b/source/Server.OpenIDConnect.Common/Configuration/OpenIDConnectConfigurationWithRoleStore.cs @@ -3,10 +3,10 @@ namespace Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Configuration { - public abstract class OpenIdConnectConfigurationWithRoleStore : OpenIdConnectConfigurationStore, IOpenIDConnectConfigurationWithRoleStore + public abstract class OpenIDConnectConfigurationWithRoleStore : OpenIDConnectConfigurationStore, IOpenIDConnectConfigurationWithRoleStore where TConfiguration : OpenIDConnectConfigurationWithRole, IId, new() { - protected OpenIdConnectConfigurationWithRoleStore(IConfigurationStore configurationStore) : base(configurationStore) + protected OpenIDConnectConfigurationWithRoleStore(IConfigurationStore configurationStore) : base(configurationStore) { } diff --git a/source/Server.OpenIDConnect.Common/Configuration/OpenIDConnectConfigureCommands.cs b/source/Server.OpenIDConnect.Common/Configuration/OpenIDConnectConfigureCommands.cs index cd30ada..caac939 100644 --- a/source/Server.OpenIDConnect.Common/Configuration/OpenIDConnectConfigureCommands.cs +++ b/source/Server.OpenIDConnect.Common/Configuration/OpenIDConnectConfigureCommands.cs @@ -1,20 +1,21 @@ using System; using System.Collections.Generic; using System.Linq; +using Octopus.Data.Model; using Octopus.Diagnostics; using Octopus.Server.Extensibility.Extensions.Infrastructure.Configuration; using Octopus.Server.Extensibility.HostServices.Web; namespace Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Configuration { - public abstract class OpenIdConnectConfigureCommands : IContributeToConfigureCommand + public abstract class OpenIDConnectConfigureCommands : IContributeToConfigureCommand where TStore : IOpenIDConnectConfigurationStore { protected readonly ISystemLog Log; protected readonly Lazy ConfigurationStore; readonly Lazy webPortalConfigurationStore; - protected OpenIdConnectConfigureCommands( + protected OpenIDConnectConfigureCommands( ISystemLog log, Lazy configurationStore, Lazy webPortalConfigurationStore) @@ -61,6 +62,11 @@ protected IEnumerable GetCoreOptions(bool hide) ConfigurationStore.Value.SetClientId(v); Log.Info($"{ConfigurationSettingsName} ClientId set to: {v}"); }, hide: hide); + yield return new ConfigureCommandOption($"{ConfigurationSettingsName}ClientSecret=", $"Follow our documentation to find the Client Secret for {ConfigurationSettingsName}.", v => + { + ConfigurationStore.Value.SetClientSecret(v.ToSensitiveString()); + Log.Info($"{ConfigurationSettingsName} ClientSecret set to: {v}"); + }, hide: hide); } public virtual IEnumerable GetOptions() diff --git a/source/Server.OpenIDConnect.Common/Configuration/OpenIDConnectWithClientSecretConfigurationStore.cs b/source/Server.OpenIDConnect.Common/Configuration/OpenIDConnectWithClientSecretConfigurationStore.cs deleted file mode 100644 index d479094..0000000 --- a/source/Server.OpenIDConnect.Common/Configuration/OpenIDConnectWithClientSecretConfigurationStore.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Octopus.Data.Model; -using Octopus.Data.Storage.Configuration; - -namespace Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Configuration -{ - public abstract class OpenIDConnectWithClientSecretConfigurationStore : OpenIdConnectConfigurationStore, IOpenIDConnectWithClientSecretConfigurationStore - where TConfiguration : OpenIDConnectConfigurationWithClientSecret, IId, new() - { - protected OpenIDConnectWithClientSecretConfigurationStore(IConfigurationStore configurationStore) : base(configurationStore) - { - } - - public SensitiveString? GetClientSecret() - { - return GetProperty(doc => doc.ClientSecret); - } - - public void SetClientSecret(SensitiveString? clientSecret) - { - SetProperty(doc => doc.ClientSecret = clientSecret); - } - } -} \ No newline at end of file diff --git a/source/Server.OpenIDConnect.Common/Infrastructure/Pkce.cs b/source/Server.OpenIDConnect.Common/Infrastructure/Pkce.cs new file mode 100644 index 0000000..b831dca --- /dev/null +++ b/source/Server.OpenIDConnect.Common/Infrastructure/Pkce.cs @@ -0,0 +1,48 @@ +using System; +using System.Security.Cryptography; +using System.Text; +using Microsoft.IdentityModel.Tokens; + +namespace Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Infrastructure +{ + public static class Pkce + { + /* + * https://datatracker.ietf.org/doc/html/rfc7636#section-4.1 + * code_verifier = high-entropy cryptographic random STRING using the + * unreserved characters [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~" + * from Section 2.3 of [RFC3986], with a minimum length of 43 characters + * and a maximum length of 128 characters. + * + * ABNF for "code_verifier" is as follows. + * + * code-verifier = 43*128unreserved + * unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" + * ALPHA = %x41-5A / %x61-7A + * DIGIT = %x30-39 + * + * NOTE: The code verifier SHOULD have enough entropy to make it + * impractical to guess the value. It is RECOMMENDED that the output of + * a suitable random number generator be used to create a 32-octet + * sequence. The octet sequence is then base64url-encoded to produce a + * 43-octet URL safe string to use as the code verifier. + */ + public static string GenerateCodeVerifier() + { + var data = new byte[32]; + RandomNumberGenerator.Fill(data); + return Base64UrlEncoder.Encode(data); + } + + /* + * https://datatracker.ietf.org/doc/html/rfc7636#section-4.2 + * code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) + */ + public static string GenerateCodeChallenge(string codeVerifier) + { + using var sha = SHA256.Create(); + var challengeBytes = sha.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier)); + return Base64UrlEncoder.Encode(challengeBytes); + } + } +} \ No newline at end of file diff --git a/source/Server.OpenIDConnect.Common/Infrastructure/PkceBlob.cs b/source/Server.OpenIDConnect.Common/Infrastructure/PkceBlob.cs new file mode 100644 index 0000000..26c6b17 --- /dev/null +++ b/source/Server.OpenIDConnect.Common/Infrastructure/PkceBlob.cs @@ -0,0 +1,18 @@ +using System; + +namespace Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Infrastructure +{ + public class PkceBlob + { + public Guid RequestId { get; } + public string CodeVerifier { get; } + public DateTimeOffset TimeStamp { get; } + + public PkceBlob(Guid requestId, string codeVerifier, DateTimeOffset timeStamp) + { + RequestId = requestId; + CodeVerifier = codeVerifier; + TimeStamp = timeStamp; + } + } +} \ No newline at end of file diff --git a/source/Server.OpenIDConnect.Common/Issuer/AuthorizationEndpointUrlBuilder.cs b/source/Server.OpenIDConnect.Common/Issuer/AuthorizationEndpointUrlBuilder.cs index 4d4c1fa..7d0cf67 100644 --- a/source/Server.OpenIDConnect.Common/Issuer/AuthorizationEndpointUrlBuilder.cs +++ b/source/Server.OpenIDConnect.Common/Issuer/AuthorizationEndpointUrlBuilder.cs @@ -7,7 +7,7 @@ namespace Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Issue public abstract class AuthorizationEndpointUrlBuilder : IAuthorizationEndpointUrlBuilder where TStore : IOpenIDConnectConfigurationStore { - protected readonly TStore ConfigurationStore; + protected TStore ConfigurationStore { get; } readonly IUrlEncoder urlEncoder; protected AuthorizationEndpointUrlBuilder(TStore configurationStore, IUrlEncoder urlEncoder) @@ -16,10 +16,10 @@ protected AuthorizationEndpointUrlBuilder(TStore configurationStore, IUrlEncoder this.urlEncoder = urlEncoder; } - protected virtual string ResponseType => OpenIDConnectConfiguration.DefaultResponseType; + protected virtual string ResponseType => ConfigurationStore.HasClientSecret ? OpenIDConnectConfiguration.AuthCodeResponseType : OpenIDConnectConfiguration.HybridResponseType; protected virtual string ResponseMode => OpenIDConnectConfiguration.DefaultResponseMode; - public virtual string Build(string requestDirectoryPath, IssuerConfiguration issuerConfiguration, string nonce, string? state = null) + public virtual string Build(string requestDirectoryPath, IssuerConfiguration issuerConfiguration, string? nonce = null, string? state = null, string? codeChallenge = null) { if (issuerConfiguration == null) throw new ArgumentException("issuerConfiguration is required", nameof(issuerConfiguration)); @@ -31,12 +31,25 @@ public virtual string Build(string requestDirectoryPath, IssuerConfiguration iss var responseMode = ResponseMode; var redirectUri = requestDirectoryPath.Trim('/') + ConfigurationStore.RedirectUri; - var url = $"{issuerEndpoint}?client_id={clientId}&scope={scope}&response_type={responseType}&response_mode={responseMode}&nonce={nonce}&redirect_uri={redirectUri}"; - + var url = $"{issuerEndpoint}?client_id={clientId}&scope={scope}&response_type={responseType}&redirect_uri={redirectUri}"; + + if (!ConfigurationStore.HasClientSecret) + { + url += $"&response_mode={responseMode}"; + } if (!string.IsNullOrWhiteSpace(state)) { url += $"&state={urlEncoder.UrlEncode(state)}"; } + if (!string.IsNullOrWhiteSpace(nonce)) + { + url += $"&nonce={nonce}"; + } + if (!string.IsNullOrWhiteSpace(codeChallenge)) + { + url += $"&code_challenge={codeChallenge}"; + url += "&code_challenge_method=S256"; + } return url; } diff --git a/source/Server.OpenIDConnect.Common/Issuer/IAuthorizationEndpointUrlBuilder.cs b/source/Server.OpenIDConnect.Common/Issuer/IAuthorizationEndpointUrlBuilder.cs index 81404ae..410e910 100644 --- a/source/Server.OpenIDConnect.Common/Issuer/IAuthorizationEndpointUrlBuilder.cs +++ b/source/Server.OpenIDConnect.Common/Issuer/IAuthorizationEndpointUrlBuilder.cs @@ -2,6 +2,6 @@ { public interface IAuthorizationEndpointUrlBuilder { - string Build(string requestDirectoryPath, IssuerConfiguration issuerConfiguration, string nonce, string? state = null); + string Build(string requestDirectoryPath, IssuerConfiguration issuerConfiguration, string? nonce = null, string? state = null, string? codeChallenge = null); } } \ No newline at end of file diff --git a/source/Server.OpenIDConnect.Common/Issuer/IssuerConfiguration.cs b/source/Server.OpenIDConnect.Common/Issuer/IssuerConfiguration.cs index ad6d597..5586572 100644 --- a/source/Server.OpenIDConnect.Common/Issuer/IssuerConfiguration.cs +++ b/source/Server.OpenIDConnect.Common/Issuer/IssuerConfiguration.cs @@ -11,5 +11,8 @@ public class IssuerConfiguration [JsonProperty("authorization_endpoint")] public string AuthorizationEndpoint { get; set; } = string.Empty; + + [JsonProperty("token_endpoint")] + public string TokenEndpoint { get; set; } = string.Empty; } } \ No newline at end of file diff --git a/source/Server.OpenIDConnect.Common/OpenIDConnectExtension.cs b/source/Server.OpenIDConnect.Common/OpenIDConnectExtension.cs index 6879319..3275f57 100644 --- a/source/Server.OpenIDConnect.Common/OpenIDConnectExtension.cs +++ b/source/Server.OpenIDConnect.Common/OpenIDConnectExtension.cs @@ -1,5 +1,6 @@ using Autofac; using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Infrastructure; +using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Web; namespace Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common { @@ -8,6 +9,7 @@ public abstract class OpenIDConnectExtension public virtual void Load(ContainerBuilder builder) { builder.RegisterType().As().InstancePerDependency(); + builder.RegisterType().As().InstancePerDependency(); } } } \ No newline at end of file diff --git a/source/Server.OpenIDConnect.Common/Server.OpenIDConnect.Common.csproj b/source/Server.OpenIDConnect.Common/Server.OpenIDConnect.Common.csproj index 6c779fe..2d4ef57 100644 --- a/source/Server.OpenIDConnect.Common/Server.OpenIDConnect.Common.csproj +++ b/source/Server.OpenIDConnect.Common/Server.OpenIDConnect.Common.csproj @@ -19,6 +19,7 @@ + @@ -26,7 +27,7 @@ - - + + \ 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..f47b434 100644 --- a/source/Server.OpenIDConnect.Common/Tokens/AuthTokenHandler.cs +++ b/source/Server.OpenIDConnect.Common/Tokens/AuthTokenHandler.cs @@ -36,7 +36,7 @@ protected AuthTokenHandler(ISystemLog log, this.keyRetriever = keyRetriever; } - protected async Task GetPrincipalFromToken(string? accessToken, string? idToken) + protected async Task GetPrincipalFromToken(string? accessToken, string? idToken) { ClaimsPrincipal? principal = null; @@ -47,7 +47,7 @@ protected async Task GetPrincipalFromToken(string? acc { ValidateActor = true, ValidateAudience = true, - ValidAudience = issuer + "/resources", + ValidAudience = ConfigurationStore.GetClientId(), ValidateIssuer = true, ValidIssuer = issuerConfig.Issuer, ValidateIssuerSigningKey = true @@ -56,41 +56,29 @@ protected async Task GetPrincipalFromToken(string? acc if (!string.IsNullOrWhiteSpace(ConfigurationStore.GetNameClaimType())) validationParameters.NameClaimType = ConfigurationStore.GetNameClaimType(); - var tokenToValidate = accessToken; - if (string.IsNullOrWhiteSpace(tokenToValidate)) - { - // if we're validating the id_token then the audience is based on the client_id, not the issuer/resource like access_token - tokenToValidate = idToken; - validationParameters.ValidAudience = ConfigurationStore.GetClientId(); - } - SetIssuerSpecificTokenValidationParameters(validationParameters); var jwt = new JwtSecurityToken(idToken); if (hmacAlgorithms.Contains(jwt.Header.Alg)) { - principal = ValidateUsingSharedSecret(validationParameters, tokenToValidate); + principal = ValidateUsingSharedSecret(validationParameters, idToken); } else { - principal = await ValidateUsingIssuerCertificate(validationParameters, tokenToValidate, issuerConfig); + principal = await ValidateUsingIssuerCertificate(validationParameters, idToken, issuerConfig); } - var error = string.Empty; - DoIssuerSpecificClaimsValidation(principal, out error); - - if (string.IsNullOrWhiteSpace(error)) - return new ClaimsPrincipleContainer(principal, GetProviderGroupIds(principal)); + DoIssuerSpecificClaimsValidation(principal, out string error); - return new ClaimsPrincipleContainer(error); + return string.IsNullOrWhiteSpace(error) ? new ClaimsPrincipalContainer(principal, GetProviderGroupIds(principal)) : new ClaimsPrincipalContainer(error); } ClaimsPrincipal ValidateUsingSharedSecret(TokenValidationParameters validationParameters, string? tokenToValidate) { - if (ConfigurationStore is IOpenIDConnectWithClientSecretConfigurationStore clientSecretStore) + if (ConfigurationStore.HasClientSecret) { - var clientSecret = clientSecretStore.GetClientSecret(); + var clientSecret = ConfigurationStore.GetClientSecret(); if (clientSecret == null) throw new ArgumentException("Client secret is not configured."); validationParameters.IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(clientSecret.Value)); diff --git a/source/Server.OpenIDConnect.Common/Tokens/ClaimsPrincipleContainer.cs b/source/Server.OpenIDConnect.Common/Tokens/ClaimsPrincipalContainer.cs similarity index 79% rename from source/Server.OpenIDConnect.Common/Tokens/ClaimsPrincipleContainer.cs rename to source/Server.OpenIDConnect.Common/Tokens/ClaimsPrincipalContainer.cs index 51046a0..39242f9 100644 --- a/source/Server.OpenIDConnect.Common/Tokens/ClaimsPrincipleContainer.cs +++ b/source/Server.OpenIDConnect.Common/Tokens/ClaimsPrincipalContainer.cs @@ -3,14 +3,14 @@ namespace Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Tokens { - public class ClaimsPrincipleContainer + public class ClaimsPrincipalContainer { - public ClaimsPrincipleContainer(string error) + public ClaimsPrincipalContainer(string error) { this.Error = error; ExternalGroupIds = Array.Empty(); } - public ClaimsPrincipleContainer(ClaimsPrincipal principal, string[] externalGroupIds) + public ClaimsPrincipalContainer(ClaimsPrincipal principal, string[] externalGroupIds) { this.Principal = principal; ExternalGroupIds = externalGroupIds; diff --git a/source/Server.OpenIDConnect.Common/Tokens/IAuthTokenHandler.cs b/source/Server.OpenIDConnect.Common/Tokens/IAuthTokenHandler.cs index a7f3ac4..5c4f3d3 100644 --- a/source/Server.OpenIDConnect.Common/Tokens/IAuthTokenHandler.cs +++ b/source/Server.OpenIDConnect.Common/Tokens/IAuthTokenHandler.cs @@ -5,6 +5,6 @@ namespace Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Token { public interface IAuthTokenHandler { - Task GetPrincipalAsync(IDictionary requestForm, out string? stateString); + Task GetPrincipalAsync(IDictionary requestForm, out string? stateString); } } \ No newline at end of file diff --git a/source/Server.OpenIDConnect.Common/Tokens/OpenIDConnectAuthTokenHandler.cs b/source/Server.OpenIDConnect.Common/Tokens/OpenIDConnectAuthTokenHandler.cs index e27db25..ec0fbea 100644 --- a/source/Server.OpenIDConnect.Common/Tokens/OpenIDConnectAuthTokenHandler.cs +++ b/source/Server.OpenIDConnect.Common/Tokens/OpenIDConnectAuthTokenHandler.cs @@ -20,7 +20,7 @@ protected OpenIDConnectAuthTokenHandler( { } - public Task GetPrincipalAsync(IDictionary requestForm, out string? stateString) + public Task GetPrincipalAsync(IDictionary requestForm, out string? stateString) { stateString = null; @@ -28,7 +28,7 @@ public Task GetPrincipalAsync(IDictionary GetPrincipalAsync(IDictionary where TAuthTokenHandler : IAuthTokenHandler + { + readonly TAuthTokenHandler authTokenHandler; + readonly IPrincipalToUserResourceMapper principalToUserResourceMapper; + readonly IAuthCookieCreator authCookieCreator; + readonly IInvalidLoginTracker loginTracker; + readonly ISleep sleep; + readonly IUrlEncoder encoder; + + public AuthServerResponseHandler( + TAuthTokenHandler authTokenHandler, + IPrincipalToUserResourceMapper principalToUserResourceMapper, + IAuthCookieCreator authCookieCreator, + IInvalidLoginTracker loginTracker, + ISleep sleep, + IUrlEncoder encoder) + { + this.authTokenHandler = authTokenHandler; + this.principalToUserResourceMapper = principalToUserResourceMapper; + this.authCookieCreator = authCookieCreator; + this.loginTracker = loginTracker; + this.sleep = sleep; + this.encoder = encoder; + } + + public async Task GetDetailsFromRequestEnsuringNoErrors(IDictionary requestForm) + { + var principalContainer = await authTokenHandler.GetPrincipalAsync(requestForm, out _); + UserAuthenticatedValidator.ValidatePrincipalContainer(principalContainer); + + return principalContainer; + } + + public void ValidateState(IOctoRequest request, string state) + { + var expectedStateHash = request.Cookies.ContainsKey(UserAuthConstants.OctopusStateCookieName) + ? encoder.UrlDecode(request.Cookies[UserAuthConstants.OctopusStateCookieName]) + : string.Empty; + UserAuthenticatedValidator.ValidateExpectedStateHashIsNotEmpty(expectedStateHash); + UserAuthenticatedValidator.ValidateReceivedStateIsEqualToExpectedState(State.Protect(state), expectedStateHash, state); + } + + public void ValidateNonce(IOctoRequest request, ClaimsPrincipalContainer principalContainer) + { + var expectedNonceHash = string.Empty; + if (request.Cookies.ContainsKey(UserAuthConstants.OctopusNonceCookieName)) + expectedNonceHash = encoder.UrlDecode(request.Cookies[UserAuthConstants.OctopusNonceCookieName]); + + UserAuthenticatedValidator.ValidateExpectedNonceHashIsNotEmpty(expectedNonceHash); + + var nonceFromClaims = principalContainer.Principal!.Claims.FirstOrDefault(c => c.Type == "nonce"); + UserAuthenticatedValidator.ValidateNonceFromClaimsIsNotEmpty(nonceFromClaims); + + var nonceFromClaimsHash = Nonce.Protect(nonceFromClaims!.Value); + UserAuthenticatedValidator.ValidateNonceFromClaimsHashIsEqualToExpectedNonce(expectedNonceHash, nonceFromClaimsHash, nonceFromClaims); + } + + public UserResource MapPrincipalToUserResource(ClaimsPrincipalContainer principalContainer) + { + var authenticationCandidate = principalToUserResourceMapper.MapToUserResource(principalContainer.Principal!); + UserAuthenticatedValidator.ValidateUsername(authenticationCandidate.Username); + return authenticationCandidate; + } + + public InvalidLoginAction CheckIfAuthenticationAttemptIsBanned(string username, string host) + { + var action = loginTracker.BeforeAttempt(username, host); + UserAuthenticatedValidator.ValidateUserIsNotBanned(action); + return action; + } + + public IOctoResponseProvider Success(IOctoRequest request, ISuccessResult successResult, string username, LoginState state) + { + loginTracker.RecordSucess(username, request.Host); + + UserAuthenticatedValidator.ValidateUserIsActive(successResult.Value.IsActive, username); + UserAuthenticatedValidator.ValidateUserIsNotServiceAccount(successResult.Value.IsService, username); + + var octoResponse = UserAuthenticatedValidator.Redirect.Response(state.RedirectAfterLoginTo) + .WithHeader("Expires", new[] { DateTime.UtcNow.AddYears(1).ToString("R", DateTimeFormatInfo.InvariantInfo) }) + .WithCookie(new OctoCookie(UserAuthConstants.OctopusStateCookieName, Guid.NewGuid().ToString()) { HttpOnly = true, Secure = false, Expires = DateTimeOffset.MinValue }) + .WithCookie(new OctoCookie(UserAuthConstants.OctopusNonceCookieName, Guid.NewGuid().ToString()) {HttpOnly = true, Secure = false, Expires = DateTimeOffset.MinValue}); + + var authCookies = authCookieCreator.CreateAuthCookies(successResult.Value.IdentificationToken, TimeSpan.FromDays(20), request.IsHttps, state.UsingSecureConnection); + + foreach (var cookie in authCookies) + { + octoResponse = octoResponse.WithCookie(cookie); + } + + return octoResponse; + } + + public void Failure(string username, string host, InvalidLoginAction action) + { + loginTracker.RecordFailure(username, host); + + if (action == InvalidLoginAction.Slow) + { + sleep.For(1000); + } + } + } +} \ No newline at end of file diff --git a/source/Server.OpenIDConnect.Common/Web/FailedAuthenticationException.cs b/source/Server.OpenIDConnect.Common/Web/FailedAuthenticationException.cs new file mode 100644 index 0000000..bd4a6cf --- /dev/null +++ b/source/Server.OpenIDConnect.Common/Web/FailedAuthenticationException.cs @@ -0,0 +1,11 @@ +using System; + +namespace Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Web +{ + public class FailedAuthenticationException : Exception + { + public FailedAuthenticationException(string message) : base(message) + { + } + } +} \ No newline at end of file diff --git a/source/Server.OpenIDConnect.Common/Web/IUserService.cs b/source/Server.OpenIDConnect.Common/Web/IUserService.cs new file mode 100644 index 0000000..49d73ae --- /dev/null +++ b/source/Server.OpenIDConnect.Common/Web/IUserService.cs @@ -0,0 +1,13 @@ +using System.Threading; +using Octopus.Data.Model.User; +using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Identities; +using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Infrastructure; +using Octopus.Server.Extensibility.Results; + +namespace Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Web +{ + public interface IUserService + { + IResultFromExtension GetOrCreateUser(UserResource userResource, string[] groups, string providerName, IIdentityCreator identityCreator, bool allowAutoUserCreation, CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/source/Server.OpenIDConnect.Common/Web/LoginStateWithRequestId.cs b/source/Server.OpenIDConnect.Common/Web/LoginStateWithRequestId.cs new file mode 100644 index 0000000..7c98704 --- /dev/null +++ b/source/Server.OpenIDConnect.Common/Web/LoginStateWithRequestId.cs @@ -0,0 +1,17 @@ +using System; +using Octopus.Server.Extensibility.Authentication.Resources; + +namespace Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Web +{ + internal class LoginStateWithRequestId : LoginState + { + public Guid RequestId { get; } + + public LoginStateWithRequestId(string redirectAfterLoginTo, bool usingSecureConnection, Guid requestId) + { + RedirectAfterLoginTo = redirectAfterLoginTo; + UsingSecureConnection = usingSecureConnection; + RequestId = requestId; + } + } +} \ No newline at end of file diff --git a/source/Server.OpenIDConnect.Common/Web/UserAuthenticatedAction.cs b/source/Server.OpenIDConnect.Common/Web/UserAuthenticatedAction.cs index 2004cc3..66b0d4c 100644 --- a/source/Server.OpenIDConnect.Common/Web/UserAuthenticatedAction.cs +++ b/source/Server.OpenIDConnect.Common/Web/UserAuthenticatedAction.cs @@ -1,12 +1,10 @@ using System; -using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; using Octopus.Data; using Octopus.Data.Model.User; -using Octopus.Data.Storage.User; using Octopus.Diagnostics; using Octopus.Server.Extensibility.Authentication.HostServices; using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Configuration; @@ -14,10 +12,8 @@ using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Infrastructure; using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Tokens; using Octopus.Server.Extensibility.Authentication.Resources; -using Octopus.Server.Extensibility.Authentication.Resources.Identities; using Octopus.Server.Extensibility.Extensions.Infrastructure.Web.Api; using Octopus.Server.Extensibility.HostServices.Web; -using Octopus.Server.Extensibility.Results; using Octopus.Time; namespace Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Web @@ -27,219 +23,78 @@ public abstract class UserAuthenticatedAction ExecuteAsync(IOctoRequest request) { - // Step 1: Try and get all of the details from the request making sure there are no errors passed back from the external identity provider - var principalContainer = await authTokenHandler.GetPrincipalAsync(request.Form.ToDictionary(pair => pair.Key, pair => (string?)pair.Value), out var stateStringFromRequest); - var principal = principalContainer.Principal; - if (principal == null || !string.IsNullOrEmpty(principalContainer.Error)) - { - return BadRequest($"The response from the external identity provider contained an error: {principalContainer.Error}"); - } - - // Step 2: Validate the state object we passed wasn't tampered with - const string stateDescription = "As a security precaution, Octopus ensures the state object returned from the external identity provider matches what it expected."; - var expectedStateHash = string.Empty; - if (request.Cookies.ContainsKey(UserAuthConstants.OctopusStateCookieName)) - expectedStateHash = encoder.UrlDecode(request.Cookies[UserAuthConstants.OctopusStateCookieName]); - if (string.IsNullOrWhiteSpace(expectedStateHash)) - { - return BadRequest($"User login failed: Missing State Hash Cookie. {stateDescription} In this case the Cookie containing the SHA256 hash of the state object is missing from the request."); - } - - var stateFromRequestHash = State.Protect(stateStringFromRequest); - if (stateFromRequestHash != expectedStateHash) - { - return BadRequest($"User login failed: Tampered State. {stateDescription} In this case the state object looks like it has been tampered with. The state object is '{stateStringFromRequest}'. The SHA256 hash of the state was expected to be '{expectedStateHash}' but was '{stateFromRequestHash}'."); - } - - var stateFromRequest = JsonConvert.DeserializeObject(stateStringFromRequest ?? string.Empty); - - // Step 3: Validate the nonce is as we expected to prevent replay attacks - const string nonceDescription = "As a security precaution to prevent replay attacks, Octopus ensures the nonce returned in the claims from the external identity provider matches what it expected."; - - var expectedNonceHash = string.Empty; - if (request.Cookies.ContainsKey(UserAuthConstants.OctopusNonceCookieName)) - expectedNonceHash = encoder.UrlDecode(request.Cookies[UserAuthConstants.OctopusNonceCookieName]); - - if (string.IsNullOrWhiteSpace(expectedNonceHash)) - { - return BadRequest($"User login failed: Missing Nonce Hash Cookie. {nonceDescription} In this case the Cookie containing the SHA256 hash of the nonce is missing from the request."); - } - - var nonceFromClaims = principal.Claims.FirstOrDefault(c => c.Type == "nonce"); - if (nonceFromClaims == null) + var authServerResponseHandler = new AuthServerResponseHandler( + authTokenHandler, + principalToUserResourceMapper, + authCookieCreator, + loginTracker, + sleep, + encoder); + try { - return BadRequest($"User login failed: Missing Nonce Claim. {nonceDescription} In this case the 'nonce' claim is missing from the security token."); - } + var principalContainer = await authServerResponseHandler.GetDetailsFromRequestEnsuringNoErrors(request.Form.ToDictionary(pair => pair.Key, pair => (string?)pair.Value)); - var nonceFromClaimsHash = Nonce.Protect(nonceFromClaims.Value); - if (nonceFromClaimsHash != expectedNonceHash) - { - return BadRequest($"User login failed: Tampered Nonce. {nonceDescription} In this case the nonce looks like it has been tampered with or reused. The nonce is '{nonceFromClaims}'. The SHA256 hash of the state was expected to be '{expectedNonceHash}' but was '{nonceFromClaimsHash}'."); - } + var stateStringFromRequest = request.Form.ContainsKey("state") ? request.Form["state"] : string.Empty; + authServerResponseHandler.ValidateState(request, stateStringFromRequest); + authServerResponseHandler.ValidateNonce(request, principalContainer); - // Step 4: Now the integrity of the request has been validated we can figure out which Octopus User this represents - var authenticationCandidate = principalToUserResourceMapper.MapToUserResource(principal); - if (authenticationCandidate.Username == null) - { - return BadRequest("Unable to determine username."); - } + var authenticationCandidate = authServerResponseHandler.MapPrincipalToUserResource(principalContainer); + var action = authServerResponseHandler.CheckIfAuthenticationAttemptIsBanned(authenticationCandidate.Username!, request.Host); - // Step 4a: Check if this authentication attempt is already being banned - var action = loginTracker.BeforeAttempt(authenticationCandidate.Username, request.Host); - if (action == InvalidLoginAction.Ban) - { - return BadRequest("You have had too many failed login attempts in a short period of time. Please try again later."); - } - - using (var cts = new CancellationTokenSource(TimeSpan.FromMinutes(1))) - { - // Step 4b: Try to get or create a the Octopus User this external identity represents - var userResult = GetOrCreateUser(authenticationCandidate, principalContainer.ExternalGroupIds, cts.Token); + using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(1)); + var userResult = userService.GetOrCreateUser(authenticationCandidate, principalContainer.ExternalGroupIds, ProviderName, identityCreator, configurationStore.GetAllowAutoUserCreation(), cts.Token); if (userResult is ISuccessResult successResult) { - loginTracker.RecordSucess(authenticationCandidate.Username, request.Host); - - if (!successResult.Value.IsActive) - { - return BadRequest($"The Octopus User Account '{authenticationCandidate.Username}' has been disabled by an Administrator. If you believe this to be a mistake, please contact your Octopus Administrator to have your account re-enabled."); - } - - if (successResult.Value.IsService) - { - return BadRequest($"The Octopus User Account '{authenticationCandidate.Username}' is a Service Account, which are prevented from using Octopus interactively. Service Accounts are designed to authorize external systems to access the Octopus API using an API Key."); - } - - var octoResponse = Redirect.Response(stateFromRequest.RedirectAfterLoginTo) - .WithHeader("Expires", new[] {DateTime.UtcNow.AddYears(1).ToString("R", DateTimeFormatInfo.InvariantInfo)}) - .WithCookie(new OctoCookie(UserAuthConstants.OctopusStateCookieName, Guid.NewGuid().ToString()) {HttpOnly = true, Secure = false, Expires = DateTimeOffset.MinValue}) - .WithCookie(new OctoCookie(UserAuthConstants.OctopusNonceCookieName, Guid.NewGuid().ToString()) {HttpOnly = true, Secure = false, Expires = DateTimeOffset.MinValue}); - - var authCookies = authCookieCreator.CreateAuthCookies(successResult.Value.IdentificationToken, TimeSpan.FromDays(20), request.IsHttps, stateFromRequest.UsingSecureConnection); - - foreach (var cookie in authCookies) - { - octoResponse = octoResponse.WithCookie(cookie); - } - - return octoResponse; - } - - // Step 5: Handle other types of failures - loginTracker.RecordFailure(authenticationCandidate.Username, request.Host); - - // Step 5a: Slow this potential attacker down a bit since they seem to keep failing - if (action == InvalidLoginAction.Slow) - { - sleep.For(1000); + var stateFromRequest = JsonConvert.DeserializeObject(stateStringFromRequest)!; + return authServerResponseHandler.Success(request, successResult, authenticationCandidate.Username!, stateFromRequest); } - return BadRequest($"User login failed: {((IFailureResult) userResult).ErrorString}"); + authServerResponseHandler.Failure(authenticationCandidate.Username!, request.Host, action); + throw new FailedAuthenticationException($"User login failed: {((IFailureResult) userResult).ErrorString}"); } - } - - IOctoResponseProvider BadRequest(string message) - { - log.Error(message); - return LoginFailed.Response(message); - } - - IResultFromExtension GetOrCreateUser(UserResource userResource, string[] groups, CancellationToken cancellationToken) - { - var identityToMatch = NewIdentity(userResource); - - var matchingUsers = userStore.GetByIdentity(identityToMatch); - if (matchingUsers.Count() > 1) - throw new Exception("There are multiple users with this identity. OpenID Connect identity providers do not support users with duplicate email addresses. Please remove any duplicate users, or make the email addresses unique."); - var user = matchingUsers.SingleOrDefault(); - - if (user != null) + catch (FailedAuthenticationException e) { - userStore.SetSecurityGroupIds(ProviderName, user.Id, groups, clock.GetUtcTime()); - - var identity = user.Identities.FirstOrDefault(x => MatchesProviderAndExternalId(userResource, x)); - if (identity != null) - { - return ResultFromExtension.Success(user); - } - - identity = user.Identities.FirstOrDefault(x => x.IdentityProviderName == ProviderName && x.Claims[ClaimDescriptor.EmailClaimType].Value == userResource.EmailAddress); - if (identity != null) - { - return ResultFromExtension.Success(userStore.UpdateIdentity(user.Id, identityToMatch, cancellationToken)); - } - - return ResultFromExtension.Success(userStore.AddIdentity(user.Id, identityToMatch, cancellationToken)); + return UserAuthenticatedValidator.BadRequest(log, e.Message); } - - if (!ConfigurationStore.GetAllowAutoUserCreation()) - return ResultFromExtension.Failed("User could not be located and auto user creation is not enabled."); - - var userResult = userStore.Create( - userResource.Username ?? string.Empty, - userResource.DisplayName ?? string.Empty, - userResource.EmailAddress ?? string.Empty, - cancellationToken, - new ProviderUserGroups { IdentityProviderName = ProviderName, GroupIds = groups }, - new[] { identityToMatch }); - if (userResult is IFailureResult failureResult) - return ResultFromExtension.Failed(failureResult.Errors); - return ResultFromExtension.Success(((ISuccessResult)userResult).Value); - } - - bool MatchesProviderAndExternalId(UserResource userResource, Identity x) - { - return x.IdentityProviderName == ProviderName && x.Claims.ContainsKey(IdentityCreator.ExternalIdClaimType) && x.Claims[IdentityCreator.ExternalIdClaimType].Value == userResource.ExternalId; - } - - Identity NewIdentity(UserResource userResource) - { - return identityCreator.Create(userResource); } } } diff --git a/source/Server.OpenIDConnect.Common/Web/UserAuthenticatedPkceAction.cs b/source/Server.OpenIDConnect.Common/Web/UserAuthenticatedPkceAction.cs new file mode 100644 index 0000000..7ddbd4a --- /dev/null +++ b/source/Server.OpenIDConnect.Common/Web/UserAuthenticatedPkceAction.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Octopus.Data; +using Octopus.Data.Model.User; +using Octopus.Diagnostics; +using Octopus.Server.Extensibility.Authentication.HostServices; +using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Configuration; +using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Identities; +using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Infrastructure; +using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Issuer; +using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Tokens; +using Octopus.Server.Extensibility.Extensions.Infrastructure.Web.Api; +using Octopus.Server.Extensibility.HostServices.Web; +using Octopus.Server.Extensibility.Mediator; +using Octopus.Server.MessageContracts.Features.BlobStorage; +using Octopus.Time; + +namespace Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Web +{ + public abstract class UserAuthenticatedPkceAction : IAsyncApiAction + where TStore : IOpenIDConnectConfigurationStore + where TAuthTokenHandler : IAuthTokenHandler + where TIdentityCreator : IIdentityCreator + { + readonly RequiredQueryParameterProperty codeParameter = new("code", "Authorization code provided by the identity provider"); + readonly OptionalQueryParameterProperty stateParameter = new("state", "The state value associated with the authentication session"); + + readonly ISystemLog log; + readonly TAuthTokenHandler authTokenHandler; + readonly IPrincipalToUserResourceMapper principalToUserResourceMapper; + readonly TStore configurationStore; + readonly IAuthCookieCreator authCookieCreator; + readonly IInvalidLoginTracker loginTracker; + readonly ISleep sleep; + readonly TIdentityCreator identityCreator; + readonly IUrlEncoder encoder; + readonly IIdentityProviderConfigDiscoverer identityProviderConfigDiscoverer; + readonly IMediator mediator; + readonly IUserService userService; + + protected UserAuthenticatedPkceAction(ISystemLog log, + TAuthTokenHandler authTokenHandler, + IPrincipalToUserResourceMapper principalToUserResourceMapper, + TStore configurationStore, + IAuthCookieCreator authCookieCreator, + IInvalidLoginTracker loginTracker, + ISleep sleep, + TIdentityCreator identityCreator, + IUrlEncoder encoder, + IIdentityProviderConfigDiscoverer identityProviderConfigDiscoverer, + IMediator mediator, + IUserService userService) + { + this.log = log; + this.authTokenHandler = authTokenHandler; + this.principalToUserResourceMapper = principalToUserResourceMapper; + this.configurationStore = configurationStore; + this.authCookieCreator = authCookieCreator; + this.loginTracker = loginTracker; + this.sleep = sleep; + this.identityCreator = identityCreator; + this.encoder = encoder; + this.identityProviderConfigDiscoverer = identityProviderConfigDiscoverer; + this.mediator = mediator; + this.userService = userService; + } + + protected abstract string ProviderName { get; } + + public async Task ExecuteAsync(IOctoRequest request) + { + return await request.HandleAsync(codeParameter, stateParameter, (code, state) => Handle(code, state, request)); + } + + async Task Handle(string code, string state, IOctoRequest request) + { + var host = request.Headers.ContainsKey("Host") ? request.Headers["Host"].Single() : request.Host; + var redirectUri = $"{request.Scheme}://{host}{configurationStore.RedirectUri}"; + var stateFromRequest = JsonConvert.DeserializeObject(state)!; + + using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(1)); + var codeVerifier = await GetCodeVerifier(stateFromRequest.RequestId, cts.Token); + var response = await RequestAuthToken(code, redirectUri, codeVerifier); + + var authServerResponseHandler = new AuthServerResponseHandler( + authTokenHandler, + principalToUserResourceMapper, + authCookieCreator, + loginTracker, + sleep, + encoder); + try + { + var principalContainer = await authServerResponseHandler.GetDetailsFromRequestEnsuringNoErrors(response); + + authServerResponseHandler.ValidateState(request, state); + + var authenticationCandidate = authServerResponseHandler.MapPrincipalToUserResource(principalContainer); + var action = authServerResponseHandler.CheckIfAuthenticationAttemptIsBanned(authenticationCandidate.Username!, request.Host); + + var userResult = userService.GetOrCreateUser(authenticationCandidate, principalContainer.ExternalGroupIds, ProviderName, identityCreator, configurationStore.GetAllowAutoUserCreation(), cts.Token); + if (userResult is ISuccessResult successResult) + { + return authServerResponseHandler.Success(request, successResult, authenticationCandidate.Username!, stateFromRequest); + } + + authServerResponseHandler.Failure(authenticationCandidate.Username!, request.Host, action); + throw new FailedAuthenticationException($"User login failed: {((IFailureResult) userResult).ErrorString}"); + } + catch (FailedAuthenticationException e) + { + return UserAuthenticatedValidator.BadRequest(log, e.Message); + } + } + + async Task> RequestAuthToken(string code, string redirectUri, string codeVerifier) + { + var issuerConfig = await identityProviderConfigDiscoverer.GetConfigurationAsync(configurationStore.GetIssuer() ?? string.Empty); + using var client = new HttpClient(); + var request = new HttpRequestMessage(HttpMethod.Post, issuerConfig.TokenEndpoint); + + var formValues = new Dictionary + { + ["grant_type"] = OpenIDConnectConfiguration.AuthCodeGrantType, + ["code"] = code, + ["redirect_uri"] = redirectUri, + ["client_id"] = configurationStore.GetClientId()!, + ["client_secret"] = configurationStore.GetClientSecret()!.Value, + ["code_verifier"] = codeVerifier + }; + request.Content = new FormUrlEncodedContent(formValues!); + var response = await client.SendAsync(request, HttpCompletionOption.ResponseContentRead); + var body = await response.Content.ReadAsStringAsync(); + return JsonConvert.DeserializeObject>(body)!; + } + + async Task GetCodeVerifier(Guid requestId, CancellationToken cancellationToken) + { + var blobs = await GetAllPkceBlobsBelongingToExtension(cancellationToken); + var blobFromOriginalRequest = blobs.Single(b => b.RequestId == requestId); + await RemoveBlob(blobFromOriginalRequest, cancellationToken); + await RemoveAnyExpiredBlobs(blobs, cancellationToken); + + return blobFromOriginalRequest.CodeVerifier; + } + + async Task> GetAllPkceBlobsBelongingToExtension(CancellationToken cancellationToken) + { + var allBlobsBelongingToExtension = await mediator.Request(new GetAllBlobsRequest(configurationStore.ConfigurationSettingsName), cancellationToken); + var pkceBlobs = new List(); + foreach (var blob in allBlobsBelongingToExtension.Blobs) + { + try + { + pkceBlobs.Add(JsonConvert.DeserializeObject(Encoding.UTF8.GetString(blob))!); + } + catch (Exception e) + { + log.Warn($"Could not parse blob. This is most likely not a PkceBlob and will be skipped: {e.Message}"); + } + } + return pkceBlobs; + } + + async Task RemoveAnyExpiredBlobs(IEnumerable blobs, CancellationToken cancellationToken) + { + foreach (var blob in blobs.Where(blob => DateTimeOffset.UtcNow.Subtract(blob.TimeStamp).TotalSeconds > 30)) + { + await RemoveBlob(blob, cancellationToken); + } + } + + async Task RemoveBlob(PkceBlob blob, CancellationToken cancellationToken) + { + await mediator.Do(new DeleteBlobCommand(configurationStore.ConfigurationSettingsName, blob.RequestId.ToString()), cancellationToken); + } + } +} diff --git a/source/Server.OpenIDConnect.Common/Web/UserAuthenticatedValidator.cs b/source/Server.OpenIDConnect.Common/Web/UserAuthenticatedValidator.cs new file mode 100644 index 0000000..0e623aa --- /dev/null +++ b/source/Server.OpenIDConnect.Common/Web/UserAuthenticatedValidator.cs @@ -0,0 +1,103 @@ +using System.Security.Claims; +using Octopus.Diagnostics; +using Octopus.Server.Extensibility.Authentication.HostServices; +using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Tokens; +using Octopus.Server.Extensibility.Extensions.Infrastructure.Web.Api; + +namespace Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Web +{ + public static class UserAuthenticatedValidator + { + public static readonly BadRequestRegistration LoginFailed = new("User login failed"); + public static readonly RedirectRegistration Redirect = new("Redirects back to the Octopus portal"); + + const string StateDescription = "As a security precaution, Octopus ensures the state object returned from the external identity provider matches what it expected."; + const string NonceDescription = "As a security precaution to prevent replay attacks, Octopus ensures the nonce returned in the claims from the external identity provider matches what it expected."; + + public static void ValidatePrincipalContainer(ClaimsPrincipalContainer principalContainer) + { + if (principalContainer.Principal == null || !string.IsNullOrEmpty(principalContainer.Error)) + { + throw new FailedAuthenticationException($"The response from the external identity provider contained an error: {principalContainer.Error}"); + } + } + + public static void ValidateExpectedStateHashIsNotEmpty(string? expectedStateHash) + { + if (string.IsNullOrWhiteSpace(expectedStateHash)) + { + throw new FailedAuthenticationException($"User login failed: Missing State Hash Cookie. {StateDescription} In this case the Cookie containing the SHA256 hash of the state object is missing from the request."); + } + } + + public static void ValidateReceivedStateIsEqualToExpectedState(string stateFromRequestHash, string expectedStateHash, string? stateStringFromRequest) + { + if (stateFromRequestHash != expectedStateHash) + { + throw new FailedAuthenticationException($"User login failed: Tampered State. {StateDescription} In this case the state object looks like it has been tampered with. The state object is '{stateStringFromRequest}'. The SHA256 hash of the state was expected to be '{expectedStateHash}' but was '{stateFromRequestHash}'."); + } + } + + public static void ValidateUsername(string? username) + { + if (username == null) + { + throw new FailedAuthenticationException("Unable to determine username."); + } + } + + public static void ValidateUserIsNotBanned(InvalidLoginAction action) + { + if (action == InvalidLoginAction.Ban) + { + throw new FailedAuthenticationException("You have had too many failed login attempts in a short period of time. Please try again later."); + } + } + + public static void ValidateUserIsActive(bool isActive, string username) + { + if (!isActive) + { + throw new FailedAuthenticationException($"The Octopus User Account '{username}' has been disabled by an Administrator. If you believe this to be a mistake, please contact your Octopus Administrator to have your account re-enabled."); + } + } + + public static void ValidateUserIsNotServiceAccount(bool isService, string username) + { + if (isService) + { + throw new FailedAuthenticationException($"The Octopus User Account '{username}' is a Service Account, which are prevented from using Octopus interactively. Service Accounts are designed to authorize external systems to access the Octopus API using an API Key."); + } + } + + public static void ValidateExpectedNonceHashIsNotEmpty(string? expectedNonceHash) + { + if (string.IsNullOrWhiteSpace(expectedNonceHash)) + { + throw new FailedAuthenticationException($"User login failed: Missing Nonce Hash Cookie. {NonceDescription} In this case the Cookie containing the SHA256 hash of the nonce is missing from the request."); + } + } + + public static void ValidateNonceFromClaimsIsNotEmpty(Claim? nonceFromClaims) + { + if (nonceFromClaims == null) + { + throw new FailedAuthenticationException($"User login failed: Missing Nonce Claim. {NonceDescription} In this case the 'nonce' claim is missing from the security token."); + } + } + + public static void ValidateNonceFromClaimsHashIsEqualToExpectedNonce(string nonceFromClaimsHash, string expectedNonceHash, Claim nonceFromClaims) + { + if (nonceFromClaimsHash != expectedNonceHash) + { + throw new FailedAuthenticationException($"User login failed: Tampered Nonce. {NonceDescription} In this case the nonce looks like it has been tampered with or reused. The nonce is '{nonceFromClaims}'. The SHA256 hash of the state was expected to be '{expectedNonceHash}' but was '{nonceFromClaimsHash}'."); + } + } + + public static IOctoResponseProvider BadRequest(ILog log, string message) + { + log.Error(message); + return LoginFailed.Response(message); + } + } +} \ No newline at end of file diff --git a/source/Server.OpenIDConnect.Common/Web/UserAuthenticationAction.cs b/source/Server.OpenIDConnect.Common/Web/UserAuthenticationAction.cs index 409e204..a39c4dd 100644 --- a/source/Server.OpenIDConnect.Common/Web/UserAuthenticationAction.cs +++ b/source/Server.OpenIDConnect.Common/Web/UserAuthenticationAction.cs @@ -1,4 +1,5 @@ using System; +using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; using Octopus.Diagnostics; @@ -8,6 +9,9 @@ using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Issuer; using Octopus.Server.Extensibility.Authentication.Resources; using Octopus.Server.Extensibility.Extensions.Infrastructure.Web.Api; +using Octopus.Server.Extensibility.Mediator; +using Octopus.Server.MessageContracts.Features.BlobStorage; +using JsonSerializer = System.Text.Json.JsonSerializer; namespace Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Web { @@ -26,6 +30,7 @@ public abstract class UserAuthenticationAction : IAsyncApiAction protected readonly TStore ConfigurationStore; readonly IApiActionModelBinder modelBinder; readonly IAuthenticationConfigurationStore authenticationConfigurationStore; + readonly IMediator mediator; protected UserAuthenticationAction( ISystemLog log, @@ -33,11 +38,13 @@ protected UserAuthenticationAction( IIdentityProviderConfigDiscoverer identityProviderConfigDiscoverer, IAuthorizationEndpointUrlBuilder urlBuilder, IApiActionModelBinder modelBinder, - IAuthenticationConfigurationStore authenticationConfigurationStore) + IAuthenticationConfigurationStore authenticationConfigurationStore, + IMediator mediator) { this.log = log; this.modelBinder = modelBinder; this.authenticationConfigurationStore = authenticationConfigurationStore; + this.mediator = mediator; ConfigurationStore = configurationStore; this.identityProviderConfigDiscoverer = identityProviderConfigDiscoverer; this.urlBuilder = urlBuilder; @@ -72,16 +79,11 @@ public async Task ExecuteAsync(IOctoRequest request) var issuer = ConfigurationStore.GetIssuer() ?? string.Empty; var issuerConfig = await identityProviderConfigDiscoverer.GetConfigurationAsync(issuer); - // Use a non-deterministic nonce to prevent replay attacks - var nonce = Nonce.GenerateUrlSafeNonce(); + var response = ConfigurationStore.HasClientSecret + ? await BuildAuthorizationCodePkceResponse(model, new LoginStateWithRequestId(state.RedirectAfterLoginTo, state.UsingSecureConnection, Guid.NewGuid()), issuerConfig) + : BuildHybridResponse(model, state, issuerConfig); - var stateString = JsonConvert.SerializeObject(state); - var url = urlBuilder.Build(model.ApiAbsUrl, issuerConfig, nonce, stateString); - - // These cookies are used to validate the data returned from the external identity provider - this prevents tampering - return Result.Response(new LoginRedirectLinkResponseModel {ExternalAuthenticationUrl = url}) - .WithCookie(new OctoCookie(UserAuthConstants.OctopusStateCookieName, State.Protect(stateString)) { HttpOnly = true, Secure = state.UsingSecureConnection, Expires = DateTimeOffset.UtcNow.AddMinutes(20) }) - .WithCookie(new OctoCookie(UserAuthConstants.OctopusNonceCookieName, Nonce.Protect(nonce)) { HttpOnly = true, Secure = state.UsingSecureConnection, Expires = DateTimeOffset.UtcNow.AddMinutes(20) }); + return response; } catch (ArgumentException ex) { @@ -94,5 +96,40 @@ public async Task ExecuteAsync(IOctoRequest request) return LoginFailed.Response(); } } + + async Task BuildAuthorizationCodePkceResponse(LoginRedirectLinkRequestModel model, LoginStateWithRequestId state, IssuerConfiguration issuerConfig) + { + var codeVerifier = Pkce.GenerateCodeVerifier(); + using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(1)); + await InsertPkceBlob(new PkceBlob(state.RequestId, codeVerifier, DateTimeOffset.UtcNow), state.RequestId.ToString(), cts.Token); + + var codeChallenge = Pkce.GenerateCodeChallenge(codeVerifier); + var stateString = JsonConvert.SerializeObject(state); + var url = urlBuilder.Build(model.ApiAbsUrl, issuerConfig, state: stateString, codeChallenge: codeChallenge); + var response = Result.Response(new LoginRedirectLinkResponseModel {ExternalAuthenticationUrl = url}) + .WithCookie(new OctoCookie(UserAuthConstants.OctopusStateCookieName, State.Protect(stateString)) {HttpOnly = true, Secure = state.UsingSecureConnection, Expires = DateTimeOffset.UtcNow.AddMinutes(20)}); + return response; + } + + // For backwards compatibility - if no client secret is specified then the implicit flow will attempt to be used + IOctoResponseProvider BuildHybridResponse(LoginRedirectLinkRequestModel model, LoginState state, IssuerConfiguration issuerConfig) + { + // Use a non-deterministic nonce to prevent replay attacks + var nonce = Nonce.GenerateUrlSafeNonce(); + var stateString = JsonConvert.SerializeObject(state); + var url = urlBuilder.Build(model.ApiAbsUrl, issuerConfig, nonce, stateString); + // These cookies are used to validate the data returned from the external identity provider - this prevents tampering + var response = Result.Response(new LoginRedirectLinkResponseModel {ExternalAuthenticationUrl = url}) + .WithCookie(new OctoCookie(UserAuthConstants.OctopusStateCookieName, State.Protect(stateString)) {HttpOnly = true, Secure = state.UsingSecureConnection, Expires = DateTimeOffset.UtcNow.AddMinutes(20)}) + .WithCookie(new OctoCookie(UserAuthConstants.OctopusNonceCookieName, Nonce.Protect(nonce)) {HttpOnly = true, Secure = state.UsingSecureConnection, Expires = DateTimeOffset.UtcNow.AddMinutes(20)}); + return response; + } + + async Task InsertPkceBlob(PkceBlob blob, string requestId, CancellationToken cancellationToken) + { + await mediator.Do( + new PutBlobCommand(ConfigurationStore.ConfigurationSettingsName, requestId, JsonSerializer.SerializeToUtf8Bytes(blob)), + cancellationToken); + } } } \ No newline at end of file diff --git a/source/Server.OpenIDConnect.Common/Web/UserService.cs b/source/Server.OpenIDConnect.Common/Web/UserService.cs new file mode 100644 index 0000000..5240a6b --- /dev/null +++ b/source/Server.OpenIDConnect.Common/Web/UserService.cs @@ -0,0 +1,86 @@ +using System; +using System.Linq; +using System.Threading; +using Octopus.Data; +using Octopus.Data.Model.User; +using Octopus.Data.Storage.User; +using Octopus.Server.Extensibility.Authentication.HostServices; +using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Identities; +using Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Infrastructure; +using Octopus.Server.Extensibility.Authentication.Resources.Identities; +using Octopus.Server.Extensibility.Results; +using Octopus.Time; + +namespace Octopus.Server.Extensibility.Authentication.OpenIDConnect.Common.Web +{ + public class UserService : IUserService + { + readonly IUpdateableUserStore userStore; + readonly IClock clock; + + public UserService(IUpdateableUserStore userStore, IClock clock) + { + this.userStore = userStore; + this.clock = clock; + } + + public IResultFromExtension GetOrCreateUser( + UserResource userResource, + string[] groups, + string providerName, + IIdentityCreator identityCreator, + bool allowAutoUserCreation, + CancellationToken cancellationToken) + { + var identityToMatch = NewIdentity(userResource, identityCreator); + + var matchingUsers = userStore.GetByIdentity(identityToMatch); + if (matchingUsers.Length > 1) + throw new Exception("There are multiple users with this identity. OpenID Connect identity providers do not support users with duplicate email addresses. Please remove any duplicate users, or make the email addresses unique."); + var user = matchingUsers.SingleOrDefault(); + + if (user != null) + { + userStore.SetSecurityGroupIds(providerName, user.Id, groups, clock.GetUtcTime()); + + var identity = user.Identities.FirstOrDefault(x => MatchesProviderAndExternalId(userResource, x, providerName)); + if (identity != null) + { + return ResultFromExtension.Success(user); + } + + identity = user.Identities.FirstOrDefault(x => x.IdentityProviderName == providerName && x.Claims[ClaimDescriptor.EmailClaimType].Value == userResource.EmailAddress); + if (identity != null) + { + return ResultFromExtension.Success(userStore.UpdateIdentity(user.Id, identityToMatch, cancellationToken)); + } + + return ResultFromExtension.Success(userStore.AddIdentity(user.Id, identityToMatch, cancellationToken)); + } + + if (!allowAutoUserCreation) + return ResultFromExtension.Failed("User could not be located and auto user creation is not enabled."); + + var userResult = userStore.Create( + userResource.Username ?? string.Empty, + userResource.DisplayName ?? string.Empty, + userResource.EmailAddress ?? string.Empty, + cancellationToken, + new ProviderUserGroups { IdentityProviderName = providerName, GroupIds = groups }, + new[] { identityToMatch }); + if (userResult is IFailureResult failureResult) + return ResultFromExtension.Failed(failureResult.Errors); + return ResultFromExtension.Success(((ISuccessResult)userResult).Value); + } + + bool MatchesProviderAndExternalId(UserResource userResource, Identity identity, string providerName) + { + return identity.IdentityProviderName == providerName && identity.Claims.ContainsKey(IdentityCreator.ExternalIdClaimType) && identity.Claims[IdentityCreator.ExternalIdClaimType].Value == userResource.ExternalId; + } + + Identity NewIdentity(UserResource userResource, IIdentityCreator identityCreator) + { + return identityCreator.Create(userResource); + } + } +} \ No newline at end of file