| | | 1 | | using Dapper; |
| | | 2 | | using Npgsql; |
| | | 3 | | using System.Security.Claims; |
| | | 4 | | using Syki.Back.Auth.Managers; |
| | | 5 | | using Syki.Back.Domain.Identity; |
| | | 6 | | using Microsoft.Extensions.Options; |
| | | 7 | | using Syki.Back.Features.Cross.SignIn; |
| | | 8 | | using Microsoft.AspNetCore.Authentication; |
| | | 9 | | using Microsoft.AspNetCore.Authentication.OpenIdConnect; |
| | | 10 | | using Microsoft.Extensions.DependencyInjection.Extensions; |
| | | 11 | | |
| | | 12 | | namespace Syki.Back.Auth.Schemes; |
| | | 13 | | |
| | | 14 | | public static class SsoOidcScheme |
| | | 15 | | { |
| | | 16 | | public const string Prefix = "OIDC_"; |
| | | 17 | | |
| | | 18 | | public static AuthenticationBuilder AddSsoOpenIdConnectScheme(this AuthenticationBuilder builder) |
| | | 19 | | { |
| | 2 | 20 | | builder.Services.AddSingleton<SsoSchemeManager>(); |
| | 2 | 21 | | builder.Services.AddSingleton<SsoEncryptionManager>(); |
| | | 22 | | |
| | 2 | 23 | | builder.Services.TryAddTransient<OpenIdConnectHandler>(); |
| | 2 | 24 | | builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<OpenIdConnectOptions>, OpenI |
| | | 25 | | |
| | 2 | 26 | | return builder; |
| | | 27 | | } |
| | | 28 | | |
| | | 29 | | public static void RegisterActiveSsoSchemes(this WebApplication app) |
| | | 30 | | { |
| | 0 | 31 | | using var scope = app.Services.CreateScope(); |
| | 0 | 32 | | var configuration = scope.ServiceProvider.GetRequiredService<IConfiguration>(); |
| | 0 | 33 | | var ssoSchemeManager = scope.ServiceProvider.GetRequiredService<SsoSchemeManager>(); |
| | | 34 | | |
| | | 35 | | const string sql = @" |
| | | 36 | | SELECT |
| | | 37 | | id, |
| | | 38 | | authority, |
| | | 39 | | client_id, |
| | | 40 | | external_id, |
| | | 41 | | client_secret, |
| | | 42 | | updated_at |
| | | 43 | | FROM |
| | | 44 | | syki.sso_configurations |
| | | 45 | | WHERE |
| | | 46 | | is_active = true |
| | | 47 | | "; |
| | | 48 | | |
| | 0 | 49 | | using var connection = new NpgsqlConnection(configuration.Database.ConnectionString); |
| | 0 | 50 | | var configs = connection.Query<SsoConfiguration>(sql).ToList(); |
| | | 51 | | |
| | 0 | 52 | | foreach (var config in configs) |
| | | 53 | | { |
| | 0 | 54 | | ssoSchemeManager.RegisterScheme(config); |
| | | 55 | | } |
| | 0 | 56 | | } |
| | | 57 | | |
| | | 58 | | public static void ConfigureSsoSchemeOptions(OpenIdConnectOptions options, SsoConfiguration config) |
| | | 59 | | { |
| | 0 | 60 | | options.Authority = config.Authority; |
| | 0 | 61 | | options.ClientId = config.ClientId; |
| | 0 | 62 | | options.ClientSecret = config.ClientSecret; |
| | 0 | 63 | | options.ResponseType = "code"; |
| | 0 | 64 | | options.UsePkce = true; |
| | 0 | 65 | | options.SaveTokens = false; |
| | 0 | 66 | | options.GetClaimsFromUserInfoEndpoint = true; |
| | 0 | 67 | | options.SignInScheme = SsoTempScheme.Name; |
| | 0 | 68 | | options.CallbackPath = $"/identity/sso/callback/{config.PublicId}"; |
| | | 69 | | |
| | 0 | 70 | | options.Scope.Clear(); |
| | 0 | 71 | | options.Scope.Add("openid"); |
| | 0 | 72 | | options.Scope.Add("email"); |
| | 0 | 73 | | options.Scope.Add("profile"); |
| | | 74 | | |
| | 0 | 75 | | options.Events = new OpenIdConnectEvents |
| | 0 | 76 | | { |
| | 0 | 77 | | OnRemoteFailure = HandleRemoteFailure, |
| | 0 | 78 | | OnTicketReceived = HandleTicketReceived, |
| | 0 | 79 | | OnAuthorizationCodeReceived = HandleAuthorizationCodeReceived, |
| | 0 | 80 | | OnRedirectToIdentityProvider = HandleRedirectToIdentityProvider, |
| | 0 | 81 | | }; |
| | 0 | 82 | | } |
| | | 83 | | |
| | | 84 | | private static async Task HandleRemoteFailure(RemoteFailureContext context) |
| | | 85 | | { |
| | 0 | 86 | | var frontendSettings = context.HttpContext.RequestServices.GetRequiredService<FrontendSettings>(); |
| | 0 | 87 | | var ctx = context.HttpContext.RequestServices.GetRequiredService<SykiDbContext>(); |
| | | 88 | | |
| | 0 | 89 | | context.Response.Redirect($"{frontendSettings.Url}?sso_error={nameof(SsoAuthenticationFailed)}"); |
| | 0 | 90 | | context.HandleResponse(); |
| | 0 | 91 | | } |
| | | 92 | | |
| | | 93 | | private static async Task HandleTicketReceived(TicketReceivedContext context) |
| | | 94 | | { |
| | 0 | 95 | | var services = context.HttpContext.RequestServices; |
| | 0 | 96 | | var ctx = services.GetRequiredService<SykiDbContext>(); |
| | 0 | 97 | | var authSettings = services.GetRequiredService<AuthSettings>(); |
| | 0 | 98 | | var signInService = services.GetRequiredService<SignInService>(); |
| | 0 | 99 | | var frontendSettings = services.GetRequiredService<FrontendSettings>(); |
| | | 100 | | |
| | | 101 | | // Extract email from OIDC claims |
| | 0 | 102 | | var email = context.Principal?.FindFirst(ClaimTypes.Email)?.Value ?? context.Principal?.FindFirst("email")?.Valu |
| | | 103 | | |
| | 0 | 104 | | if (email.IsEmpty()) |
| | | 105 | | { |
| | 0 | 106 | | context.Response.Redirect($"{frontendSettings.Url}?sso_error={nameof(SsoAuthenticationFailed)}"); |
| | 0 | 107 | | context.HandleResponse(); |
| | 0 | 108 | | return; |
| | | 109 | | } |
| | | 110 | | |
| | 0 | 111 | | var domain = email!.Split('@').Last().ToLowerInvariant(); |
| | | 112 | | |
| | | 113 | | // Load SSO configuration for this domain |
| | 0 | 114 | | var ssoConfigId = await ctx.WebSsoAllowedDomains.Where(x => x.Domain == domain).Select(x => x.SsoConfigurationId |
| | 0 | 115 | | var ssoConfig = await ctx.WebSsoConfigurations.Where(x => x.Id == ssoConfigId && x.IsActive).FirstOrDefaultAsync |
| | 0 | 116 | | if (ssoConfig == null) |
| | | 117 | | { |
| | 0 | 118 | | context.Response.Redirect($"{frontendSettings.Url}?sso_error={nameof(SsoNotConfiguredForDomain)}"); |
| | 0 | 119 | | context.HandleResponse(); |
| | 0 | 120 | | return; |
| | | 121 | | } |
| | | 122 | | |
| | | 123 | | // Look up user by email |
| | 0 | 124 | | var user = await ctx.Users.FirstOrDefaultAsync(u => u.Email == email); |
| | 0 | 125 | | if (user == null) |
| | | 126 | | { |
| | 0 | 127 | | context.Response.Redirect($"{frontendSettings.Url}?sso_error={nameof(SsoLoginUserNotFound)}"); |
| | 0 | 128 | | context.HandleResponse(); |
| | 0 | 129 | | return; |
| | | 130 | | } |
| | | 131 | | |
| | | 132 | | // Set EmailConfirmed if the IdP confirmed the email is verified |
| | 0 | 133 | | var emailVerifiedClaim = context.Principal?.FindFirst("email_verified")?.Value; |
| | 0 | 134 | | if (emailVerifiedClaim is "true" or "True") |
| | | 135 | | { |
| | 0 | 136 | | user.EmailConfirmed = true; |
| | | 137 | | } |
| | | 138 | | |
| | | 139 | | // Generate JWT and set cookie |
| | 0 | 140 | | await signInService.SignIn(email); |
| | | 141 | | |
| | 0 | 142 | | context.Response.Redirect(frontendSettings.BuildUrl("/home")); |
| | 0 | 143 | | context.HandleResponse(); |
| | 0 | 144 | | } |
| | | 145 | | |
| | | 146 | | /// <summary> |
| | | 147 | | /// Fires before the token exchange with the OIDC provider. |
| | | 148 | | /// Refreshes the ClientSecret if the in-memory scheme is stale (e.g., config updated on another instance). |
| | | 149 | | /// </summary> |
| | | 150 | | private static async Task HandleAuthorizationCodeReceived(AuthorizationCodeReceivedContext context) |
| | | 151 | | { |
| | 0 | 152 | | var schemeName = context.Scheme.Name; |
| | 0 | 153 | | var publicId = Guid.Parse(schemeName[Prefix.Length..]); |
| | | 154 | | |
| | 0 | 155 | | var services = context.HttpContext.RequestServices; |
| | 0 | 156 | | var ctx = services.GetRequiredService<SykiDbContext>(); |
| | 0 | 157 | | var ssoSchemeManager = services.GetRequiredService<SsoSchemeManager>(); |
| | | 158 | | |
| | 0 | 159 | | var config = await ctx.GetActiveSsoConfigForSchemeAsync(publicId); |
| | 0 | 160 | | if (config == null) return; |
| | | 161 | | |
| | 0 | 162 | | if (ssoSchemeManager.IsStale(schemeName, config.UpdatedAt)) |
| | | 163 | | { |
| | 0 | 164 | | var encryption = services.GetRequiredService<SsoEncryptionManager>(); |
| | 0 | 165 | | context.TokenEndpointRequest!.ClientSecret = encryption.Decrypt(config.ClientSecret); |
| | 0 | 166 | | ssoSchemeManager.RegisterScheme(config); |
| | | 167 | | } |
| | 0 | 168 | | } |
| | | 169 | | |
| | | 170 | | private static Task HandleRedirectToIdentityProvider(RedirectContext context) |
| | | 171 | | { |
| | 0 | 172 | | if (context.Properties.Items.TryGetValue("login_hint", out var loginHint)) |
| | | 173 | | { |
| | 0 | 174 | | context.ProtocolMessage.LoginHint = loginHint; |
| | | 175 | | } |
| | 0 | 176 | | return Task.CompletedTask; |
| | | 177 | | } |
| | | 178 | | } |