< Summary - Syki

Information
Class: Syki.Back.Auth.Schemes.SsoOidcScheme
Assembly: Back
File(s): /home/runner/work/syki/syki/Back/Auth/Schemes/SsoOidcScheme.cs
Tag: 56_26538939494
Line coverage
6%
Covered lines: 5
Uncovered lines: 78
Coverable lines: 83
Total lines: 178
Line coverage: 6%
Branch coverage
0%
Covered branches: 0
Total branches: 34
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/syki/syki/Back/Auth/Schemes/SsoOidcScheme.cs

#LineLine coverage
 1using Dapper;
 2using Npgsql;
 3using System.Security.Claims;
 4using Syki.Back.Auth.Managers;
 5using Syki.Back.Domain.Identity;
 6using Microsoft.Extensions.Options;
 7using Syki.Back.Features.Cross.SignIn;
 8using Microsoft.AspNetCore.Authentication;
 9using Microsoft.AspNetCore.Authentication.OpenIdConnect;
 10using Microsoft.Extensions.DependencyInjection.Extensions;
 11
 12namespace Syki.Back.Auth.Schemes;
 13
 14public static class SsoOidcScheme
 15{
 16    public const string Prefix = "OIDC_";
 17
 18    public static AuthenticationBuilder AddSsoOpenIdConnectScheme(this AuthenticationBuilder builder)
 19    {
 220        builder.Services.AddSingleton<SsoSchemeManager>();
 221        builder.Services.AddSingleton<SsoEncryptionManager>();
 22
 223        builder.Services.TryAddTransient<OpenIdConnectHandler>();
 224        builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<OpenIdConnectOptions>, OpenI
 25
 226        return builder;
 27    }
 28
 29    public static void RegisterActiveSsoSchemes(this WebApplication app)
 30    {
 031        using var scope = app.Services.CreateScope();
 032        var configuration = scope.ServiceProvider.GetRequiredService<IConfiguration>();
 033        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
 049        using var connection = new NpgsqlConnection(configuration.Database.ConnectionString);
 050        var configs = connection.Query<SsoConfiguration>(sql).ToList();
 51
 052        foreach (var config in configs)
 53        {
 054            ssoSchemeManager.RegisterScheme(config);
 55        }
 056    }
 57
 58    public static void ConfigureSsoSchemeOptions(OpenIdConnectOptions options, SsoConfiguration config)
 59    {
 060        options.Authority = config.Authority;
 061        options.ClientId = config.ClientId;
 062        options.ClientSecret = config.ClientSecret;
 063        options.ResponseType = "code";
 064        options.UsePkce = true;
 065        options.SaveTokens = false;
 066        options.GetClaimsFromUserInfoEndpoint = true;
 067        options.SignInScheme = SsoTempScheme.Name;
 068        options.CallbackPath = $"/identity/sso/callback/{config.PublicId}";
 69
 070        options.Scope.Clear();
 071        options.Scope.Add("openid");
 072        options.Scope.Add("email");
 073        options.Scope.Add("profile");
 74
 075        options.Events = new OpenIdConnectEvents
 076        {
 077            OnRemoteFailure = HandleRemoteFailure,
 078            OnTicketReceived = HandleTicketReceived,
 079            OnAuthorizationCodeReceived = HandleAuthorizationCodeReceived,
 080            OnRedirectToIdentityProvider = HandleRedirectToIdentityProvider,
 081        };
 082    }
 83
 84    private static async Task HandleRemoteFailure(RemoteFailureContext context)
 85    {
 086        var frontendSettings = context.HttpContext.RequestServices.GetRequiredService<FrontendSettings>();
 087        var ctx = context.HttpContext.RequestServices.GetRequiredService<SykiDbContext>();
 88
 089        context.Response.Redirect($"{frontendSettings.Url}?sso_error={nameof(SsoAuthenticationFailed)}");
 090        context.HandleResponse();
 091    }
 92
 93    private static async Task HandleTicketReceived(TicketReceivedContext context)
 94    {
 095        var services = context.HttpContext.RequestServices;
 096        var ctx = services.GetRequiredService<SykiDbContext>();
 097        var authSettings = services.GetRequiredService<AuthSettings>();
 098        var signInService = services.GetRequiredService<SignInService>();
 099        var frontendSettings = services.GetRequiredService<FrontendSettings>();
 100
 101        // Extract email from OIDC claims
 0102        var email = context.Principal?.FindFirst(ClaimTypes.Email)?.Value ?? context.Principal?.FindFirst("email")?.Valu
 103
 0104        if (email.IsEmpty())
 105        {
 0106            context.Response.Redirect($"{frontendSettings.Url}?sso_error={nameof(SsoAuthenticationFailed)}");
 0107            context.HandleResponse();
 0108            return;
 109        }
 110
 0111        var domain = email!.Split('@').Last().ToLowerInvariant();
 112
 113        // Load SSO configuration for this domain
 0114        var ssoConfigId = await ctx.WebSsoAllowedDomains.Where(x => x.Domain == domain).Select(x => x.SsoConfigurationId
 0115        var ssoConfig = await ctx.WebSsoConfigurations.Where(x => x.Id == ssoConfigId && x.IsActive).FirstOrDefaultAsync
 0116        if (ssoConfig == null)
 117        {
 0118            context.Response.Redirect($"{frontendSettings.Url}?sso_error={nameof(SsoNotConfiguredForDomain)}");
 0119            context.HandleResponse();
 0120            return;
 121        }
 122
 123        // Look up user by email
 0124        var user = await ctx.Users.FirstOrDefaultAsync(u => u.Email == email);
 0125        if (user == null)
 126        {
 0127            context.Response.Redirect($"{frontendSettings.Url}?sso_error={nameof(SsoLoginUserNotFound)}");
 0128            context.HandleResponse();
 0129            return;
 130        }
 131
 132        // Set EmailConfirmed if the IdP confirmed the email is verified
 0133        var emailVerifiedClaim = context.Principal?.FindFirst("email_verified")?.Value;
 0134        if (emailVerifiedClaim is "true" or "True")
 135        {
 0136            user.EmailConfirmed = true;
 137        }
 138
 139        // Generate JWT and set cookie
 0140        await signInService.SignIn(email);
 141
 0142        context.Response.Redirect(frontendSettings.BuildUrl("/home"));
 0143        context.HandleResponse();
 0144    }
 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    {
 0152        var schemeName = context.Scheme.Name;
 0153        var publicId = Guid.Parse(schemeName[Prefix.Length..]);
 154
 0155        var services = context.HttpContext.RequestServices;
 0156        var ctx = services.GetRequiredService<SykiDbContext>();
 0157        var ssoSchemeManager = services.GetRequiredService<SsoSchemeManager>();
 158
 0159        var config = await ctx.GetActiveSsoConfigForSchemeAsync(publicId);
 0160        if (config == null) return;
 161
 0162        if (ssoSchemeManager.IsStale(schemeName, config.UpdatedAt))
 163        {
 0164            var encryption = services.GetRequiredService<SsoEncryptionManager>();
 0165            context.TokenEndpointRequest!.ClientSecret = encryption.Decrypt(config.ClientSecret);
 0166            ssoSchemeManager.RegisterScheme(config);
 167        }
 0168    }
 169
 170    private static Task HandleRedirectToIdentityProvider(RedirectContext context)
 171    {
 0172        if (context.Properties.Items.TryGetValue("login_hint", out var loginHint))
 173        {
 0174            context.ProtocolMessage.LoginHint = loginHint;
 175        }
 0176        return Task.CompletedTask;
 177    }
 178}