From 8bb01b10abe16bbc11b92458566b185e61893f31 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 15 May 2024 12:40:47 -0600 Subject: [PATCH 01/14] attempt to add openid connect support --- .idea/.idea.LexBox/.idea/.gitignore | 2 + .idea/.idea.LexBox/.idea/dataSources.xml | 57 +- .idea/.idea.LexBox/.idea/indexLayout.xml | 3 +- .idea/.idea.LexBox/.idea/vcs.xml | 3 +- backend/LexBoxApi/Auth/AuthKernel.cs | 77 +- .../LexBoxApi/Controllers/LoginController.cs | 167 ++- backend/LexBoxApi/LexBoxApi.csproj | 4 + backend/LexData/DataKernel.cs | 1 + backend/LexData/LexData.csproj | 11 +- .../20240515164328_AddOpenId.Designer.cs | 1271 +++++++++++++++++ .../Migrations/20240515164328_AddOpenId.cs | 166 +++ .../LexBoxDbContextModelSnapshot.cs | 246 +++- backend/LexData/SeedingData.cs | 32 +- .../Fixtures/TestingServicesFixture.cs | 2 +- backend/Testing/Testing.csproj | 1 - frontend/vite.config.ts | 24 +- 16 files changed, 2018 insertions(+), 49 deletions(-) create mode 100644 backend/LexData/Migrations/20240515164328_AddOpenId.Designer.cs create mode 100644 backend/LexData/Migrations/20240515164328_AddOpenId.cs diff --git a/.idea/.idea.LexBox/.idea/.gitignore b/.idea/.idea.LexBox/.idea/.gitignore index 4ec73edd9..daa11e94f 100644 --- a/.idea/.idea.LexBox/.idea/.gitignore +++ b/.idea/.idea.LexBox/.idea/.gitignore @@ -11,3 +11,5 @@ # Datasource local storage ignored files /dataSources/ /dataSources.local.xml +# GitHub Copilot persisted chat sessions +/copilot/chatSessions diff --git a/.idea/.idea.LexBox/.idea/dataSources.xml b/.idea/.idea.LexBox/.idea/dataSources.xml index eceae86ec..b6ad8028d 100644 --- a/.idea/.idea.LexBox/.idea/dataSources.xml +++ b/.idea/.idea.LexBox/.idea/dataSources.xml @@ -5,32 +5,17 @@ postgresql true org.postgresql.Driver - jdbc:postgresql://localhost/lexbox + jdbc:postgresql://localhost:5433/lexbox - + - $ProjectFileDir$ - - mariadb - true - redmine data - org.mariadb.jdbc.Driver - jdbc:mariadb://localhost:3306 - - - - - - - $ProjectFileDir$ - postgresql true @@ -65,5 +50,41 @@ $ProjectFileDir$ + + mongo + true + true + com.dbschema.MongoJdbcDriver + mongodb://localhost:27018 + $ProjectFileDir$ + + + mongo + true + true + com.dbschema.MongoJdbcDriver + mongodb://localhost:27017 + + + + + + $ProjectFileDir$ + + + sqlite.xerial + true + org.sqlite.JDBC + jdbc:sqlite:C:\dev\LexBox\backend\LocalWebApp\sena-3.sqlite + + + + $ProjectFileDir$ + + + file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.43.0/org/xerial/sqlite-jdbc/3.43.0.0/sqlite-jdbc-3.43.0.0.jar + + + - + \ No newline at end of file diff --git a/.idea/.idea.LexBox/.idea/indexLayout.xml b/.idea/.idea.LexBox/.idea/indexLayout.xml index bfb0056af..24b63bcb7 100644 --- a/.idea/.idea.LexBox/.idea/indexLayout.xml +++ b/.idea/.idea.LexBox/.idea/indexLayout.xml @@ -2,6 +2,7 @@ + deployment frontend hasura hg-web @@ -19,4 +20,4 @@ frontend/.svelte-kit/output - \ No newline at end of file + diff --git a/.idea/.idea.LexBox/.idea/vcs.xml b/.idea/.idea.LexBox/.idea/vcs.xml index 30edf8684..9041d4ebe 100644 --- a/.idea/.idea.LexBox/.idea/vcs.xml +++ b/.idea/.idea.LexBox/.idea/vcs.xml @@ -12,5 +12,6 @@ + - \ No newline at end of file + diff --git a/backend/LexBoxApi/Auth/AuthKernel.cs b/backend/LexBoxApi/Auth/AuthKernel.cs index 892deaa5c..5c6444503 100644 --- a/backend/LexBoxApi/Auth/AuthKernel.cs +++ b/backend/LexBoxApi/Auth/AuthKernel.cs @@ -5,12 +5,17 @@ using LexBoxApi.Auth.Requirements; using LexBoxApi.Controllers; using LexCore.Auth; +using LexData; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Logging; using Microsoft.OpenApi.Models; +using OpenIddict.Abstractions; +using OpenIddict.Server; +using OpenIddict.Server.AspNetCore; +using OpenIddict.Validation.AspNetCore; namespace LexBoxApi.Auth; @@ -78,7 +83,12 @@ public static void AddLexBoxAuth(IServiceCollection services, { options.ForwardDefaultSelector = context => { - + if (context.Request.Headers.ContainsKey("Authorization") && + context.Request.Headers.Authorization.ToString().StartsWith("Bearer")) + { + //fow now this will use oauth + return OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme; + } if (context.Request.IsJwtRequest()) { return JwtBearerDefaults.AuthenticationScheme; @@ -101,9 +111,9 @@ public static void AddLexBoxAuth(IServiceCollection services, .AddCookie(options => { configuration.Bind("Authentication:Cookie", options); - options.LoginPath = "/api/login"; + options.LoginPath = "/login"; options.Cookie.Name = AuthCookieName; - options.ForwardChallenge = JwtBearerDefaults.AuthenticationScheme; + // options.ForwardChallenge = JwtBearerDefaults.AuthenticationScheme; options.ForwardForbid = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(options => @@ -185,6 +195,67 @@ public static void AddLexBoxAuth(IServiceCollection services, } }); }); + + services.Add(ScopeRequestFixer.Descriptor.ServiceDescriptor); + + //openid server + services.AddOpenIddict() + .AddCore(options => + { + options.UseEntityFrameworkCore().UseDbContext(); + options.UseQuartz(); + }) + .AddServer(options => + { + options.RegisterScopes("openid", "profile", "email"); + options.RegisterClaims("aud", "email", "exp", "iss", "iat", "sub", "name"); + options.SetAuthorizationEndpointUris("api/login/open-id-auth"); + options.SetTokenEndpointUris("api/connect/token"); + options.SetIntrospectionEndpointUris("api/connect/introspect"); + options.SetUserinfoEndpointUris("api/connect/userinfo"); + options.Configure(serverOptions => serverOptions.Handlers.Add(ScopeRequestFixer.Descriptor)); + + options.AllowAuthorizationCodeFlow() + .AllowImplicitFlow()//implicit flow used for response type token + .AllowRefreshTokenFlow(); + + options.IgnoreResponseTypePermissions(); + options.IgnoreScopePermissions(); + + //todo setup encryption + options.AddDevelopmentEncryptionCertificate().AddEphemeralSigningKey(); + + options.UseAspNetCore() + .EnableAuthorizationEndpointPassthrough(); + }) + .AddValidation(options => + { + options.UseLocalServer(); + options.UseAspNetCore(); + options.AddAudiences(Enum.GetValues().Where(a => a != LexboxAudience.Unknown).Select(a => a.ToString()).ToArray()); + options.EnableAuthorizationEntryValidation(); + }); + } + + public sealed class ScopeRequestFixer : IOpenIddictServerHandler + { + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(OpenIddictServerHandlers.Exchange.ValidateResourceOwnerCredentialsParameters.Descriptor.Order + 1) + .SetType(OpenIddictServerHandlerType.Custom) + .Build(); + + public ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context) + { + if (!string.IsNullOrEmpty(context.Request.Scope) && (context.Request.IsAuthorizationCodeGrantType() || + context.Request.IsDeviceCodeGrantType())) + { + context.Request.Scope = null; + } + + return default; + } } public static AuthorizationPolicyBuilder RequireDefaultLexboxAuth(this AuthorizationPolicyBuilder builder) diff --git a/backend/LexBoxApi/Controllers/LoginController.cs b/backend/LexBoxApi/Controllers/LoginController.cs index 944ead716..7e5cddb15 100644 --- a/backend/LexBoxApi/Controllers/LoginController.cs +++ b/backend/LexBoxApi/Controllers/LoginController.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using System.ComponentModel.DataAnnotations; using LexBoxApi.Auth; using LexBoxApi.Auth.Attributes; @@ -16,7 +17,13 @@ using Microsoft.AspNetCore.Http; using LexCore.Entities; using System.Security.Claims; +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Google; +using Microsoft.Extensions.Primitives; +using Microsoft.IdentityModel.Tokens; +using OpenIddict.Abstractions; +using OpenIddict.Server.AspNetCore; namespace LexBoxApi.Controllers; @@ -38,7 +45,6 @@ public class LoginController( /// [HttpGet("loginRedirect")] [AllowAnyAudience] - public async Task LoginRedirect( string jwt, // This is required because auth looks for a jwt in the query string string returnTo) @@ -53,6 +59,7 @@ public async Task LoginRedirect( return await EmailLinkExpired(); } } + await HttpContext.SignInAsync(User, new AuthenticationProperties { IsPersistent = true }); return Redirect(returnTo); @@ -87,6 +94,7 @@ public async Task CompleteGoogleLogin(ClaimsPrincipal? principal, string { (authUser, userEntity) = await lexAuthService.GetUser(googleEmail); } + if (authUser is null) { authUser = new LexAuthUser() @@ -102,19 +110,20 @@ public async Task CompleteGoogleLogin(ClaimsPrincipal? principal, string Locale = locale ?? LexCore.Entities.User.DefaultLocalizationCode, Locked = null, }; - var queryParams = new Dictionary() { - {"email", googleEmail}, - {"name", googleName}, - {"returnTo", returnTo}, + var queryParams = new Dictionary() + { + { "email", googleEmail }, { "name", googleName }, { "returnTo", returnTo }, }; var queryString = QueryString.Create(queryParams); returnTo = "/register" + queryString.ToString(); } + if (userEntity is not null && !foundGoogleId) { userEntity.GoogleId = googleId; await lexBoxDbContext.SaveChangesAsync(); } + await HttpContext.SignInAsync(authUser.GetPrincipal("google"), new AuthenticationProperties { IsPersistent = true }); return returnTo; @@ -158,6 +167,150 @@ public async Task> VerifyEmail( return Redirect(returnTo); } + [HttpGet("open-id-auth")] + [HttpPost("open-id-auth")] + public async Task Authorize(string? returnUrl = null) + { + var request = HttpContext.GetOpenIddictServerRequest(); + if (request is null) + { + return BadRequest(); + } + + // Retrieve the user principal stored in the authentication cookie. + // If a max_age parameter was provided, ensure that the cookie is not too old. + // If the user principal can't be extracted or the cookie is too old, redirect the user to the login page. + var result = await HttpContext.AuthenticateAsync(); + if (!result.Succeeded || (request.MaxAge != null && result.Properties?.IssuedUtc != null && + DateTimeOffset.UtcNow - result.Properties.IssuedUtc > + TimeSpan.FromSeconds(request.MaxAge.Value))) + { + // If the client application requested promptless authentication, + // return an error indicating that the user is not logged in. + if (request.HasPrompt(OpenIddictConstants.Prompts.None)) + { + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = + OpenIddictConstants.Errors.LoginRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = + "The user is not logged in." + })); + } + + return Challenge( + authenticationSchemes: CookieAuthenticationDefaults.AuthenticationScheme, + properties: new AuthenticationProperties + { + RedirectUri = Request.PathBase + Request.Path + QueryString.Create( + Request.HasFormContentType ? [.. Request.Form] : [.. Request.Query]) + }); + } + + // If prompt=login was specified by the client application, + // immediately return the user agent to the login page. + if (request.HasPrompt(OpenIddictConstants.Prompts.Login)) + { + // To avoid endless login -> authorization redirects, the prompt=login flag + // is removed from the authorization request payload before redirecting the user. + var prompt = string.Join(" ", request.GetPrompts().Remove(OpenIddictConstants.Prompts.Login)); + + var parameters = Request.HasFormContentType + ? Request.Form.Where(parameter => parameter.Key != OpenIddictConstants.Parameters.Prompt).ToList() + : Request.Query.Where(parameter => parameter.Key != OpenIddictConstants.Parameters.Prompt).ToList(); + + parameters.Add(KeyValuePair.Create(OpenIddictConstants.Parameters.Prompt, new StringValues(prompt))); + + return Challenge( + authenticationSchemes: CookieAuthenticationDefaults.AuthenticationScheme, + properties: new AuthenticationProperties + { + RedirectUri = Request.PathBase + Request.Path + QueryString.Create(parameters) + }); + } + + //todo create identity from db + // Create the claims-based identity that will be used by OpenIddict to generate tokens. + var identity = new ClaimsIdentity(result.Principal!.Claims, + authenticationType: TokenValidationParameters.DefaultAuthenticationType, + nameType: OpenIddictConstants.Claims.Name, + roleType: OpenIddictConstants.Claims.Role); + identity.SetScopes(new[] + { + OpenIddictConstants.Scopes.OfflineAccess, + OpenIddictConstants.Scopes.OpenId, + OpenIddictConstants.Scopes.Email, + OpenIddictConstants.Scopes.Profile + }.Intersect(request.GetScopes())); + identity.SetAudiences(LexboxAudience.LexboxApi.ToString()); + identity.SetDestinations(claim => claim.Type switch + { + // Note: always include acr and auth_time in the identity token as they must be flowed + // from the authorization endpoint to the identity token returned from the token endpoint. + OpenIddictConstants.Claims.AuthenticationContextReference or + OpenIddictConstants.Claims.AuthenticationTime + => ImmutableArray.Create(OpenIddictConstants.Destinations.IdentityToken), + + // Note: when an authorization code or access token is returned, don't add the profile, email, + // phone and address claims to the identity tokens as they are returned from the userinfo endpoint. + OpenIddictConstants.Claims.Subject or + OpenIddictConstants.Claims.Name or + OpenIddictConstants.Claims.Gender or + OpenIddictConstants.Claims.GivenName or + OpenIddictConstants.Claims.MiddleName or + OpenIddictConstants.Claims.FamilyName or + OpenIddictConstants.Claims.Nickname or + OpenIddictConstants.Claims.PreferredUsername or + OpenIddictConstants.Claims.Birthdate or + OpenIddictConstants.Claims.Profile or + OpenIddictConstants.Claims.Picture or + OpenIddictConstants.Claims.Website or + OpenIddictConstants.Claims.Locale or + OpenIddictConstants.Claims.Zoneinfo or + OpenIddictConstants.Claims.UpdatedAt when + identity.HasScope(OpenIddictConstants.Permissions.Scopes.Profile) && + !request.HasResponseType(OpenIddictConstants.Permissions.ResponseTypes.Code) && + !request.HasResponseType(OpenIddictConstants.Permissions.ResponseTypes.Token) => + [ + OpenIddictConstants.Destinations.AccessToken, + OpenIddictConstants.Destinations.IdentityToken + ], + + OpenIddictConstants.Claims.Email when + identity.HasScope(OpenIddictConstants.Permissions.Scopes.Email) && + !request.HasResponseType(OpenIddictConstants.Permissions.ResponseTypes.Code) && + !request.HasResponseType(OpenIddictConstants.Permissions.ResponseTypes.Token) => + [ + OpenIddictConstants.Destinations.AccessToken, + OpenIddictConstants.Destinations.IdentityToken + ], + + OpenIddictConstants.Claims.PhoneNumber when + identity.HasScope(OpenIddictConstants.Permissions.Scopes.Phone) && + !request.HasResponseType(OpenIddictConstants.Permissions.ResponseTypes.Code) && + !request.HasResponseType(OpenIddictConstants.Permissions.ResponseTypes.Token) => + [ + OpenIddictConstants.Destinations.AccessToken, + OpenIddictConstants.Destinations.IdentityToken + ], + + OpenIddictConstants.Claims.Address when + identity.HasScope(OpenIddictConstants.Permissions.Scopes.Address) && + !request.HasResponseType(OpenIddictConstants.Permissions.ResponseTypes.Code) && + !request.HasResponseType(OpenIddictConstants.Permissions.ResponseTypes.Token) => + [ + OpenIddictConstants.Destinations.AccessToken, + OpenIddictConstants.Destinations.IdentityToken + ], + + _ => [OpenIddictConstants.Destinations.AccessToken] + }); + + return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + [HttpPost] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] @@ -219,7 +372,9 @@ public async Task ForgotPassword(ForgotPasswordInput input) return Ok(); } - public record ResetPasswordRequest([Required(AllowEmptyStrings = false)] string PasswordHash, int? PasswordStrength); + public record ResetPasswordRequest( + [Required(AllowEmptyStrings = false)] string PasswordHash, + int? PasswordStrength); [HttpPost("resetPassword")] [RequireAudience(LexboxAudience.ForgotPassword)] diff --git a/backend/LexBoxApi/LexBoxApi.csproj b/backend/LexBoxApi/LexBoxApi.csproj index cc3c9dfe2..95c931403 100644 --- a/backend/LexBoxApi/LexBoxApi.csproj +++ b/backend/LexBoxApi/LexBoxApi.csproj @@ -32,8 +32,12 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/backend/LexData/DataKernel.cs b/backend/LexData/DataKernel.cs index e89df4e26..90a9474d8 100644 --- a/backend/LexData/DataKernel.cs +++ b/backend/LexData/DataKernel.cs @@ -17,6 +17,7 @@ public static void AddLexData(this IServiceCollection services, options.EnableDetailedErrors(); options.UseNpgsql(serviceProvider.GetRequiredService>().Value.LexBoxConnectionString); options.UseProjectables(); + options.UseOpenIddict(); }, dbContextLifeTime); services.AddLogging(); services.AddHealthChecks() diff --git a/backend/LexData/LexData.csproj b/backend/LexData/LexData.csproj index 62125507c..2e02aead9 100644 --- a/backend/LexData/LexData.csproj +++ b/backend/LexData/LexData.csproj @@ -10,17 +10,18 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - + - - + + + diff --git a/backend/LexData/Migrations/20240515164328_AddOpenId.Designer.cs b/backend/LexData/Migrations/20240515164328_AddOpenId.Designer.cs new file mode 100644 index 000000000..95c2c35ef --- /dev/null +++ b/backend/LexData/Migrations/20240515164328_AddOpenId.Designer.cs @@ -0,0 +1,1271 @@ +// +using System; +using System.Collections.Generic; +using LexData; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LexData.Migrations +{ + [DbContext(typeof(LexBoxDbContext))] + [Migration("20240515164328_AddOpenId")] + partial class AddOpenId + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:case_insensitive", "und-u-ks-level2,und-u-ks-level2,icu,False") + .HasAnnotation("ProductVersion", "8.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BlobData") + .HasColumnType("bytea") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_blob_triggers", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCalendar", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Calendar") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName"); + + b.ToTable("qrtz_calendars", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCronTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("text") + .HasColumnName("cron_expression"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_cron_triggers", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzFiredTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("EntryId") + .HasColumnType("text") + .HasColumnName("entry_id"); + + b.Property("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.Property("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property("State") + .IsRequired() + .HasColumnType("text") + .HasColumnName("state"); + + b.Property("TriggerGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("TriggerName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_qrtz_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_qrtz_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_qrtz_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_qrtz_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_qrtz_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_nm_gp"); + + b.ToTable("qrtz_fired_triggers", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsDurable") + .HasColumnType("bool") + .HasColumnName("is_durable"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("IsUpdateData") + .HasColumnType("bool") + .HasColumnName("is_update_data"); + + b.Property("JobClassName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_class_name"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_qrtz_j_req_recovery"); + + b.ToTable("qrtz_job_details", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzLock", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("LockName") + .HasColumnType("text") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName"); + + b.ToTable("qrtz_locks", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzPausedTriggerGroup", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup"); + + b.ToTable("qrtz_paused_trigger_grps", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSchedulerState", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("InstanceName") + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName"); + + b.ToTable("qrtz_scheduler_state", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimplePropertyTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BooleanProperty1") + .HasColumnType("bool") + .HasColumnName("bool_prop_1"); + + b.Property("BooleanProperty2") + .HasColumnType("bool") + .HasColumnName("bool_prop_2"); + + b.Property("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property("IntegerProperty1") + .HasColumnType("integer") + .HasColumnName("int_prop_1"); + + b.Property("IntegerProperty2") + .HasColumnType("integer") + .HasColumnName("int_prop_2"); + + b.Property("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property("StringProperty1") + .HasColumnType("text") + .HasColumnName("str_prop_1"); + + b.Property("StringProperty2") + .HasColumnType("text") + .HasColumnName("str_prop_2"); + + b.Property("StringProperty3") + .HasColumnType("text") + .HasColumnName("str_prop_3"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_simprop_triggers", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimpleTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_simple_triggers", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("MisfireInstruction") + .HasColumnType("smallint") + .HasColumnName("misfire_instr"); + + b.Property("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property("TriggerState") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_state"); + + b.Property("TriggerType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_qrtz_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_qrtz_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_qrtz_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup"); + + b.ToTable("qrtz_triggers", "quartz"); + }); + + modelBuilder.Entity("LexCore.Entities.DraftProject", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsConfidential") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectManagerId") + .HasColumnType("uuid"); + + b.Property("RetentionPolicy") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("ProjectManagerId"); + + b.ToTable("DraftProjects"); + }); + + modelBuilder.Entity("LexCore.Entities.FlexProjectMetadata", b => + { + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("LexEntryCount") + .HasColumnType("integer"); + + b.HasKey("ProjectId"); + + b.ToTable("FlexProjectMetadata"); + }); + + modelBuilder.Entity("LexCore.Entities.OrgMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("OrgId") + .HasColumnType("uuid"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrgId"); + + b.HasIndex("UserId", "OrgId") + .IsUnique(); + + b.ToTable("OrgMembers", (string)null); + }); + + modelBuilder.Entity("LexCore.Entities.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Orgs", (string)null); + }); + + modelBuilder.Entity("LexCore.Entities.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IsConfidential") + .HasColumnType("boolean"); + + b.Property("LastCommit") + .HasColumnType("timestamp with time zone"); + + b.Property("MigratedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("ProjectOrigin") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("ResetStatus") + .HasColumnType("integer"); + + b.Property("RetentionPolicy") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("LexCore.Entities.ProjectUsers", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UserId", "ProjectId") + .IsUnique(); + + b.ToTable("ProjectUsers"); + }); + + modelBuilder.Entity("LexCore.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CanCreateProjects") + .HasColumnType("boolean"); + + b.Property("CreatedById") + .HasColumnType("uuid"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Email") + .HasColumnType("text") + .UseCollation("case_insensitive"); + + b.Property("EmailVerified") + .HasColumnType("boolean"); + + b.Property("GoogleId") + .HasColumnType("text"); + + b.Property("IsAdmin") + .HasColumnType("boolean"); + + b.Property("LastActive") + .HasColumnType("timestamp with time zone"); + + b.Property("LocalizationCode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("en"); + + b.Property("Locked") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("PasswordStrength") + .HasColumnType("integer"); + + b.Property("Salt") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Username") + .HasColumnType("text") + .UseCollation("case_insensitive"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("LexData.Entities.CrdtCommit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("text"); + + b.Property("ParentHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.ComplexProperty>("HybridDateTime", "LexData.Entities.CrdtCommit.HybridDateTime#HybridDateTime", b1 => + { + b1.IsRequired(); + + b1.Property("Counter") + .HasColumnType("bigint"); + + b1.Property("DateTime") + .HasColumnType("timestamp with time zone"); + }); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("CrdtCommits", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasColumnType("text"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("JsonWebKeySet") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedirectUris") + .HasColumnType("text"); + + b.Property("Requirements") + .HasColumnType("text"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddictApplications", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Scopes") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Descriptions") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Resources") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddictScopes", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("AuthorizationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("BlobTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCronTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("CronTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimplePropertyTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("SimplePropertyTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimpleTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("SimpleTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", "JobDetail") + .WithMany("Triggers") + .HasForeignKey("SchedulerName", "JobName", "JobGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobDetail"); + }); + + modelBuilder.Entity("LexCore.Entities.DraftProject", b => + { + b.HasOne("LexCore.Entities.User", "ProjectManager") + .WithMany() + .HasForeignKey("ProjectManagerId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("ProjectManager"); + }); + + modelBuilder.Entity("LexCore.Entities.FlexProjectMetadata", b => + { + b.HasOne("LexCore.Entities.Project", null) + .WithOne("FlexProjectMetadata") + .HasForeignKey("LexCore.Entities.FlexProjectMetadata", "ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("LexCore.Entities.OrgMember", b => + { + b.HasOne("LexCore.Entities.Organization", "Organization") + .WithMany("Members") + .HasForeignKey("OrgId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LexCore.Entities.User", "User") + .WithMany("Organizations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LexCore.Entities.Project", b => + { + b.HasOne("LexCore.Entities.Project", null) + .WithMany() + .HasForeignKey("ParentId"); + }); + + modelBuilder.Entity("LexCore.Entities.ProjectUsers", b => + { + b.HasOne("LexCore.Entities.Project", "Project") + .WithMany("Users") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LexCore.Entities.User", "User") + .WithMany("Projects") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LexCore.Entities.User", b => + { + b.HasOne("LexCore.Entities.User", "CreatedBy") + .WithMany("UsersICreated") + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("CreatedBy"); + }); + + modelBuilder.Entity("LexData.Entities.CrdtCommit", b => + { + b.HasOne("LexCore.Entities.FlexProjectMetadata", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("Crdt.Core.ChangeEntity", "ChangeEntities", b1 => + { + b1.Property("CrdtCommitId") + .HasColumnType("uuid"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b1.Property("Change") + .HasColumnType("text"); + + b1.Property("CommitId") + .HasColumnType("uuid"); + + b1.Property("EntityId") + .HasColumnType("uuid"); + + b1.Property("Index") + .HasColumnType("integer"); + + b1.HasKey("CrdtCommitId", "Id"); + + b1.ToTable("CrdtCommits"); + + b1.ToJson("ChangeEntities"); + + b1.WithOwner() + .HasForeignKey("CrdtCommitId"); + }); + + b.Navigation("ChangeEntities"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b => + { + b.Navigation("Triggers"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b => + { + b.Navigation("BlobTriggers"); + + b.Navigation("CronTriggers"); + + b.Navigation("SimplePropertyTriggers"); + + b.Navigation("SimpleTriggers"); + }); + + modelBuilder.Entity("LexCore.Entities.Organization", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("LexCore.Entities.Project", b => + { + b.Navigation("FlexProjectMetadata"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("LexCore.Entities.User", b => + { + b.Navigation("Organizations"); + + b.Navigation("Projects"); + + b.Navigation("UsersICreated"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/LexData/Migrations/20240515164328_AddOpenId.cs b/backend/LexData/Migrations/20240515164328_AddOpenId.cs new file mode 100644 index 000000000..952d9f453 --- /dev/null +++ b/backend/LexData/Migrations/20240515164328_AddOpenId.cs @@ -0,0 +1,166 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LexData.Migrations +{ + /// + public partial class AddOpenId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "OpenIddictApplications", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + ApplicationType = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + ClientId = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + ClientSecret = table.Column(type: "text", nullable: true), + ClientType = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + ConcurrencyToken = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + ConsentType = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + DisplayName = table.Column(type: "text", nullable: true), + DisplayNames = table.Column(type: "text", nullable: true), + JsonWebKeySet = table.Column(type: "text", nullable: true), + Permissions = table.Column(type: "text", nullable: true), + PostLogoutRedirectUris = table.Column(type: "text", nullable: true), + Properties = table.Column(type: "text", nullable: true), + RedirectUris = table.Column(type: "text", nullable: true), + Requirements = table.Column(type: "text", nullable: true), + Settings = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddictApplications", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "OpenIddictScopes", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + ConcurrencyToken = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Description = table.Column(type: "text", nullable: true), + Descriptions = table.Column(type: "text", nullable: true), + DisplayName = table.Column(type: "text", nullable: true), + DisplayNames = table.Column(type: "text", nullable: true), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + Properties = table.Column(type: "text", nullable: true), + Resources = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddictScopes", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "OpenIddictAuthorizations", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + ApplicationId = table.Column(type: "text", nullable: true), + ConcurrencyToken = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + CreationDate = table.Column(type: "timestamp with time zone", nullable: true), + Properties = table.Column(type: "text", nullable: true), + Scopes = table.Column(type: "text", nullable: true), + Status = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Subject = table.Column(type: "character varying(400)", maxLength: 400, nullable: true), + Type = table.Column(type: "character varying(50)", maxLength: 50, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddictAuthorizations", x => x.Id); + table.ForeignKey( + name: "FK_OpenIddictAuthorizations_OpenIddictApplications_Application~", + column: x => x.ApplicationId, + principalTable: "OpenIddictApplications", + principalColumn: "Id"); + }); + + migrationBuilder.CreateTable( + name: "OpenIddictTokens", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + ApplicationId = table.Column(type: "text", nullable: true), + AuthorizationId = table.Column(type: "text", nullable: true), + ConcurrencyToken = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + CreationDate = table.Column(type: "timestamp with time zone", nullable: true), + ExpirationDate = table.Column(type: "timestamp with time zone", nullable: true), + Payload = table.Column(type: "text", nullable: true), + Properties = table.Column(type: "text", nullable: true), + RedemptionDate = table.Column(type: "timestamp with time zone", nullable: true), + ReferenceId = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + Status = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Subject = table.Column(type: "character varying(400)", maxLength: 400, nullable: true), + Type = table.Column(type: "character varying(50)", maxLength: 50, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddictTokens", x => x.Id); + table.ForeignKey( + name: "FK_OpenIddictTokens_OpenIddictApplications_ApplicationId", + column: x => x.ApplicationId, + principalTable: "OpenIddictApplications", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_OpenIddictTokens_OpenIddictAuthorizations_AuthorizationId", + column: x => x.AuthorizationId, + principalTable: "OpenIddictAuthorizations", + principalColumn: "Id"); + }); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictApplications_ClientId", + table: "OpenIddictApplications", + column: "ClientId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictAuthorizations_ApplicationId_Status_Subject_Type", + table: "OpenIddictAuthorizations", + columns: new[] { "ApplicationId", "Status", "Subject", "Type" }); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictScopes_Name", + table: "OpenIddictScopes", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictTokens_ApplicationId_Status_Subject_Type", + table: "OpenIddictTokens", + columns: new[] { "ApplicationId", "Status", "Subject", "Type" }); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictTokens_AuthorizationId", + table: "OpenIddictTokens", + column: "AuthorizationId"); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictTokens_ReferenceId", + table: "OpenIddictTokens", + column: "ReferenceId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "OpenIddictScopes"); + + migrationBuilder.DropTable( + name: "OpenIddictTokens"); + + migrationBuilder.DropTable( + name: "OpenIddictAuthorizations"); + + migrationBuilder.DropTable( + name: "OpenIddictApplications"); + } + } +} diff --git a/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs b/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs index f05e25c5f..c9859b38b 100644 --- a/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs +++ b/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs @@ -18,7 +18,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder .HasAnnotation("Npgsql:CollationDefinition:case_insensitive", "und-u-ks-level2,und-u-ks-level2,icu,False") - .HasAnnotation("ProductVersion", "8.0.2") + .HasAnnotation("ProductVersion", "8.0.5") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -772,6 +772,214 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Users"); }); + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasColumnType("text"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("JsonWebKeySet") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedirectUris") + .HasColumnType("text"); + + b.Property("Requirements") + .HasColumnType("text"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddictApplications", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Scopes") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Descriptions") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Resources") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddictScopes", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("AuthorizationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", (string)null); + }); + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b => { b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") @@ -901,6 +1109,30 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("CreatedBy"); }); + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b => { b.Navigation("Triggers"); @@ -937,6 +1169,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("UsersICreated"); }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); #pragma warning restore 612, 618 } } diff --git a/backend/LexData/SeedingData.cs b/backend/LexData/SeedingData.cs index 52dd16ee7..031e0b3a2 100644 --- a/backend/LexData/SeedingData.cs +++ b/backend/LexData/SeedingData.cs @@ -4,10 +4,11 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; +using OpenIddict.Abstractions; namespace LexData; -public class SeedingData(LexBoxDbContext lexBoxDbContext, IOptions dbConfig, IHostEnvironment environment) +public class SeedingData(LexBoxDbContext lexBoxDbContext, IOptions dbConfig, IHostEnvironment environment, IOpenIddictApplicationManager applicationManager) { public static readonly Guid TestAdminId = new("cf430ec9-e721-450a-b6a1-9a853212590b"); public static readonly Guid QaAdminId = new("99b00c58-0dc7-4fe4-b6f2-c27b828811e0"); @@ -16,6 +17,7 @@ public class SeedingData(LexBoxDbContext lexBoxDbContext, IOptions dbC public async Task SeedIfNoUsers(CancellationToken cancellationToken = default) { + await SeedOpenId(cancellationToken); if (await lexBoxDbContext.Users.CountAsync(cancellationToken) > 0) { return; @@ -163,6 +165,34 @@ public async Task SeedDatabase(CancellationToken cancellationToken = default) await lexBoxDbContext.SaveChangesAsync(cancellationToken); } + private async Task SeedOpenId(CancellationToken cancellationToken = default) + { + if (await applicationManager.FindByClientIdAsync("becf2856-0690-434b-b192-a4032b72067f", cancellationToken) is null) + { + await applicationManager.CreateAsync(new OpenIddictApplicationDescriptor + { + ClientId = "becf2856-0690-434b-b192-a4032b72067f",//must be guid for MSAL + ClientType = OpenIddictConstants.ClientTypes.Public, + ApplicationType = OpenIddictConstants.ApplicationTypes.Web, + // ClientSecret = "test-secret", + Permissions = + { + OpenIddictConstants.Permissions.Endpoints.Authorization, + OpenIddictConstants.Permissions.Endpoints.Token, + OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode, + OpenIddictConstants.Permissions.GrantTypes.RefreshToken, + OpenIddictConstants.Permissions.ResponseTypes.Code, + OpenIddictConstants.Permissions.ResponseTypes.Token, + OpenIddictConstants.Permissions.Scopes.Email, + OpenIddictConstants.Permissions.Scopes.Profile + }, + RedirectUris = { new Uri("https://openidconnect.net/callback"), new Uri("http://localhost:9999") }, + + }, + cancellationToken); + } + } + public async Task CleanUpSeedData() { await lexBoxDbContext.Users.Where(u => u.Salt == PwSalt).ExecuteDeleteAsync(); diff --git a/backend/Testing/Fixtures/TestingServicesFixture.cs b/backend/Testing/Fixtures/TestingServicesFixture.cs index d7dc95e45..644bd41c7 100644 --- a/backend/Testing/Fixtures/TestingServicesFixture.cs +++ b/backend/Testing/Fixtures/TestingServicesFixture.cs @@ -34,7 +34,7 @@ private static void ConfigureBaseServices(IServiceCollection services) EnvironmentName = Environments.Development }); services.AddSingleton(new ConfigurationManager()); - services.AddLexData(true, ServiceLifetime.Singleton); + services.AddLexData(true, dbContextLifeTime: ServiceLifetime.Singleton); } public ServiceProvider ConfigureServices(Action configureServices) diff --git a/backend/Testing/Testing.csproj b/backend/Testing/Testing.csproj index 723f085e1..d21c187e2 100644 --- a/backend/Testing/Testing.csproj +++ b/backend/Testing/Testing.csproj @@ -38,7 +38,6 @@ - runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 84f9c64e9..82a76159b 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -4,7 +4,7 @@ import { defineConfig } from 'vitest/config'; import { gqlOptions } from './gql-codegen'; // eslint-disable-next-line no-restricted-imports import precompileIntl from 'svelte-intl-precompile/sveltekit-plugin'; -import {searchForWorkspaceRoot} from 'vite'; +import {type ProxyOptions, searchForWorkspaceRoot} from 'vite'; import { sveltekit } from '@sveltejs/kit/vite'; @@ -12,6 +12,10 @@ import { sveltekit } from '@sveltejs/kit/vite'; const exposeServer = false; +const lexboxServer: ProxyOptions = { + target: 'https://localhost:7075', + secure: false +}; export default defineConfig({ build: { @@ -30,7 +34,7 @@ export default defineConfig({ codegen(gqlOptions), precompileIntl('src/lib/i18n/locales'), sveltekit(), - exposeServer ? basicSsl() : null, // crypto.subtle is only availble on secure connections + basicSsl() ], optimizeDeps: { }, @@ -40,6 +44,9 @@ export default defineConfig({ server: { port: 3000, host: exposeServer, + https: { + + }, strictPort: true, fs: { allow: [ @@ -47,15 +54,10 @@ export default defineConfig({ ] }, proxy: process.env['DockerDev'] ? undefined : { - '/v1/traces': { - target: 'http://localhost:4318' - }, - '/api': { - target: 'http://localhost:5158' - }, - '/hg': { - target: 'http://localhost:5158' - } + '/v1/traces': lexboxServer, + '/api': lexboxServer, + '/hg': lexboxServer, + '/.well-known': lexboxServer } }, }); From 906d2a23710a259aac40a1f247e175140c3eab7d Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Thu, 16 May 2024 10:27:35 -0600 Subject: [PATCH 02/14] update dotnet ef tool version --- .config/dotnet-tools.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 558293e1d..93ec8eb6c 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "7.0.10", + "version": "8.0.5", "commands": [ "dotnet-ef" ] From 347cebb446df76f74274c366a297db97f2ccc54b Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Thu, 16 May 2024 14:46:31 -0600 Subject: [PATCH 03/14] disable openId in production using a config option. This lets us figure out how to we want to do key storage in production --- backend/LexBoxApi/Auth/AuthKernel.cs | 48 ++++++++----------- backend/LexBoxApi/Auth/OpenIdOptions.cs | 9 ++++ backend/LexBoxApi/Auth/ScopeRequestFixer.cs | 28 +++++++++++ .../LexBoxApi/Controllers/LoginController.cs | 18 ------- .../LexBoxApi/appsettings.Development.json | 7 ++- backend/LexBoxApi/appsettings.json | 3 ++ 6 files changed, 65 insertions(+), 48 deletions(-) create mode 100644 backend/LexBoxApi/Auth/OpenIdOptions.cs create mode 100644 backend/LexBoxApi/Auth/ScopeRequestFixer.cs diff --git a/backend/LexBoxApi/Auth/AuthKernel.cs b/backend/LexBoxApi/Auth/AuthKernel.cs index 5c6444503..177fe703a 100644 --- a/backend/LexBoxApi/Auth/AuthKernel.cs +++ b/backend/LexBoxApi/Auth/AuthKernel.cs @@ -12,9 +12,6 @@ using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Logging; using Microsoft.OpenApi.Models; -using OpenIddict.Abstractions; -using OpenIddict.Server; -using OpenIddict.Server.AspNetCore; using OpenIddict.Validation.AspNetCore; namespace LexBoxApi.Auth; @@ -71,6 +68,8 @@ public static void AddLexBoxAuth(IServiceCollection services, .BindConfiguration("Authentication:Jwt") .ValidateDataAnnotations() .ValidateOnStart(); + services.AddOptions() + .BindConfiguration("Authentication:OpenId"); services.AddAuthentication(options => { options.DefaultScheme = DefaultScheme; @@ -84,7 +83,8 @@ public static void AddLexBoxAuth(IServiceCollection services, options.ForwardDefaultSelector = context => { if (context.Request.Headers.ContainsKey("Authorization") && - context.Request.Headers.Authorization.ToString().StartsWith("Bearer")) + context.Request.Headers.Authorization.ToString().StartsWith("Bearer") && + context.RequestServices.GetService>()?.Value.Enable == true) { //fow now this will use oauth return OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme; @@ -196,6 +196,12 @@ public static void AddLexBoxAuth(IServiceCollection services, }); }); + var openIdOptions = configuration.GetSection("Authentication:OpenId").Get(); + if (openIdOptions?.Enable == true) AddOpenId(services, environment); + } + + private static void AddOpenId(IServiceCollection services, IWebHostEnvironment environment) + { services.Add(ScopeRequestFixer.Descriptor.ServiceDescriptor); //openid server @@ -221,9 +227,16 @@ public static void AddLexBoxAuth(IServiceCollection services, options.IgnoreResponseTypePermissions(); options.IgnoreScopePermissions(); - - //todo setup encryption - options.AddDevelopmentEncryptionCertificate().AddEphemeralSigningKey(); + if (environment.IsDevelopment()) + { + options.AddDevelopmentEncryptionCertificate(); + options.AddDevelopmentSigningCertificate(); + } + else + { + //see docs: https://documentation.openiddict.com/configuration/encryption-and-signing-credentials.html + throw new NotImplementedException("need to implement loading keys from a file"); + } options.UseAspNetCore() .EnableAuthorizationEndpointPassthrough(); @@ -237,27 +250,6 @@ public static void AddLexBoxAuth(IServiceCollection services, }); } - public sealed class ScopeRequestFixer : IOpenIddictServerHandler - { - public static OpenIddictServerHandlerDescriptor Descriptor { get; } - = OpenIddictServerHandlerDescriptor.CreateBuilder() - .UseSingletonHandler() - .SetOrder(OpenIddictServerHandlers.Exchange.ValidateResourceOwnerCredentialsParameters.Descriptor.Order + 1) - .SetType(OpenIddictServerHandlerType.Custom) - .Build(); - - public ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context) - { - if (!string.IsNullOrEmpty(context.Request.Scope) && (context.Request.IsAuthorizationCodeGrantType() || - context.Request.IsDeviceCodeGrantType())) - { - context.Request.Scope = null; - } - - return default; - } - } - public static AuthorizationPolicyBuilder RequireDefaultLexboxAuth(this AuthorizationPolicyBuilder builder) { return builder.RequireAuthenticatedUser() diff --git a/backend/LexBoxApi/Auth/OpenIdOptions.cs b/backend/LexBoxApi/Auth/OpenIdOptions.cs new file mode 100644 index 000000000..eeef9e5e2 --- /dev/null +++ b/backend/LexBoxApi/Auth/OpenIdOptions.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace LexBoxApi.Auth; + +public class OpenIdOptions +{ + [Required] + public required bool Enable { get; set; } +} diff --git a/backend/LexBoxApi/Auth/ScopeRequestFixer.cs b/backend/LexBoxApi/Auth/ScopeRequestFixer.cs new file mode 100644 index 000000000..9048091ca --- /dev/null +++ b/backend/LexBoxApi/Auth/ScopeRequestFixer.cs @@ -0,0 +1,28 @@ +using OpenIddict.Abstractions; +using OpenIddict.Server; + +namespace LexBoxApi.Auth; + +/// +/// the MSAL library makes requests with the scope parameter, which is invalid, this attempts to remove the scope before it's rejected +/// +public sealed class ScopeRequestFixer : IOpenIddictServerHandler +{ + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(OpenIddictServerHandlers.Exchange.ValidateResourceOwnerCredentialsParameters.Descriptor.Order + 1) + .SetType(OpenIddictServerHandlerType.Custom) + .Build(); + + public ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context) + { + if (!string.IsNullOrEmpty(context.Request.Scope) && (context.Request.IsAuthorizationCodeGrantType() || + context.Request.IsDeviceCodeGrantType())) + { + context.Request.Scope = null; + } + + return default; + } +} diff --git a/backend/LexBoxApi/Controllers/LoginController.cs b/backend/LexBoxApi/Controllers/LoginController.cs index 7e5cddb15..de8521508 100644 --- a/backend/LexBoxApi/Controllers/LoginController.cs +++ b/backend/LexBoxApi/Controllers/LoginController.cs @@ -287,24 +287,6 @@ OpenIddictConstants.Claims.Email when OpenIddictConstants.Destinations.IdentityToken ], - OpenIddictConstants.Claims.PhoneNumber when - identity.HasScope(OpenIddictConstants.Permissions.Scopes.Phone) && - !request.HasResponseType(OpenIddictConstants.Permissions.ResponseTypes.Code) && - !request.HasResponseType(OpenIddictConstants.Permissions.ResponseTypes.Token) => - [ - OpenIddictConstants.Destinations.AccessToken, - OpenIddictConstants.Destinations.IdentityToken - ], - - OpenIddictConstants.Claims.Address when - identity.HasScope(OpenIddictConstants.Permissions.Scopes.Address) && - !request.HasResponseType(OpenIddictConstants.Permissions.ResponseTypes.Code) && - !request.HasResponseType(OpenIddictConstants.Permissions.ResponseTypes.Token) => - [ - OpenIddictConstants.Destinations.AccessToken, - OpenIddictConstants.Destinations.IdentityToken - ], - _ => [OpenIddictConstants.Destinations.AccessToken] }); diff --git a/backend/LexBoxApi/appsettings.Development.json b/backend/LexBoxApi/appsettings.Development.json index 154f63fdb..805f5e4c9 100644 --- a/backend/LexBoxApi/appsettings.Development.json +++ b/backend/LexBoxApi/appsettings.Development.json @@ -7,10 +7,10 @@ } }, "DbConfig": { - "LexBoxConnectionString": "Host=localhost;Port=5433;Username=postgres;Password=972b722e63f549938d07bd8c4ee5086c;Database=lexbox;Include Error Detail=true", + "LexBoxConnectionString": "Host=localhost;Port=5433;Username=postgres;Password=972b722e63f549938d07bd8c4ee5086c;Database=lexbox;Include Error Detail=true" }, "LfClassicConfig": { - "ConnectionString": "mongodb://localhost:27017", + "ConnectionString": "mongodb://localhost:27017" }, "ForwardedHeadersOptions": { "KnownNetworks": [ @@ -54,6 +54,9 @@ "Google": { "ClientId": "694338503380-rf3t82f3kqc7l0orjletk2r58lep0lvi.apps.googleusercontent.com", "ClientSecret": "__REPLACE__" + }, + "OpenId": { + "Enable": true } }, "Tus" : { diff --git a/backend/LexBoxApi/appsettings.json b/backend/LexBoxApi/appsettings.json index a5369becd..5462f8b02 100644 --- a/backend/LexBoxApi/appsettings.json +++ b/backend/LexBoxApi/appsettings.json @@ -60,6 +60,9 @@ // properties declared in CookieAuthenticationOptions // 15 days, 0 hours "ExpireTimeSpan": "15.00:00" + }, + "OpenId": { + "Enable": false } }, "Otel": { From 79a028c861f4dede4ea2e2380644b16cf169e708 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Thu, 16 May 2024 15:20:15 -0600 Subject: [PATCH 04/14] fix redirect loop trying to send the user to the login page, if that's handled by aspnet it'll result in a redirect loop --- backend/LexBoxApi/Program.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/LexBoxApi/Program.cs b/backend/LexBoxApi/Program.cs index 995d8e192..c867153ca 100644 --- a/backend/LexBoxApi/Program.cs +++ b/backend/LexBoxApi/Program.cs @@ -170,6 +170,10 @@ .RequireAuthorization(new AdminRequiredAttribute()); // /api routes should never make it to this point, they should be handled by the controllers, so return 404 app.Map("/api/{**catch-all}", () => Results.NotFound()).AllowAnonymous(); + +//should normally be handled by svelte, but if it does reach this we need to return a 401, otherwise we'll get stuck in a redirect loop +app.Map("/login", Results.Unauthorized).AllowAnonymous(); + app.MapSyncProxy(AuthKernel.DefaultScheme); await app.RunAsync(); From b37c11263dd9108d889402fed7e3afdce5d23b7e Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Thu, 16 May 2024 16:20:06 -0600 Subject: [PATCH 05/14] trim down claims check list since most aren't used, allow name claim to end up in ID token for testing purposes --- .../LexBoxApi/Controllers/LoginController.cs | 27 +++++++------------ backend/LexData/SeedingData.cs | 9 ++++--- 2 files changed, 14 insertions(+), 22 deletions(-) diff --git a/backend/LexBoxApi/Controllers/LoginController.cs b/backend/LexBoxApi/Controllers/LoginController.cs index de8521508..372ff6d01 100644 --- a/backend/LexBoxApi/Controllers/LoginController.cs +++ b/backend/LexBoxApi/Controllers/LoginController.cs @@ -255,33 +255,24 @@ OpenIddictConstants.Claims.AuthenticationContextReference or // Note: when an authorization code or access token is returned, don't add the profile, email, // phone and address claims to the identity tokens as they are returned from the userinfo endpoint. - OpenIddictConstants.Claims.Subject or + OpenIddictConstants.Claims.Subject or OpenIddictConstants.Claims.Name or - OpenIddictConstants.Claims.Gender or - OpenIddictConstants.Claims.GivenName or - OpenIddictConstants.Claims.MiddleName or - OpenIddictConstants.Claims.FamilyName or - OpenIddictConstants.Claims.Nickname or - OpenIddictConstants.Claims.PreferredUsername or - OpenIddictConstants.Claims.Birthdate or - OpenIddictConstants.Claims.Profile or - OpenIddictConstants.Claims.Picture or - OpenIddictConstants.Claims.Website or OpenIddictConstants.Claims.Locale or - OpenIddictConstants.Claims.Zoneinfo or OpenIddictConstants.Claims.UpdatedAt when - identity.HasScope(OpenIddictConstants.Permissions.Scopes.Profile) && - !request.HasResponseType(OpenIddictConstants.Permissions.ResponseTypes.Code) && - !request.HasResponseType(OpenIddictConstants.Permissions.ResponseTypes.Token) => + identity.HasScope(OpenIddictConstants.Scopes.Profile) + //todo consider if this should be enabled or not. It prevents the name from ending up in the ID token, but I guess that's supposed to be handled with the userinfo endpoint + // && !request.HasResponseType(OpenIddictConstants.ResponseTypes.Code) + // && !request.HasResponseType(OpenIddictConstants.ResponseTypes.Token) + => [ OpenIddictConstants.Destinations.AccessToken, OpenIddictConstants.Destinations.IdentityToken ], OpenIddictConstants.Claims.Email when - identity.HasScope(OpenIddictConstants.Permissions.Scopes.Email) && - !request.HasResponseType(OpenIddictConstants.Permissions.ResponseTypes.Code) && - !request.HasResponseType(OpenIddictConstants.Permissions.ResponseTypes.Token) => + identity.HasScope(OpenIddictConstants.Scopes.Email) && + !request.HasResponseType(OpenIddictConstants.ResponseTypes.Code) && + !request.HasResponseType(OpenIddictConstants.ResponseTypes.Token) => [ OpenIddictConstants.Destinations.AccessToken, OpenIddictConstants.Destinations.IdentityToken diff --git a/backend/LexData/SeedingData.cs b/backend/LexData/SeedingData.cs index 031e0b3a2..4a68a3474 100644 --- a/backend/LexData/SeedingData.cs +++ b/backend/LexData/SeedingData.cs @@ -167,26 +167,27 @@ public async Task SeedDatabase(CancellationToken cancellationToken = default) private async Task SeedOpenId(CancellationToken cancellationToken = default) { - if (await applicationManager.FindByClientIdAsync("becf2856-0690-434b-b192-a4032b72067f", cancellationToken) is null) + const string clientId = "becf2856-0690-434b-b192-a4032b72067f"; + if (await applicationManager.FindByClientIdAsync(clientId, cancellationToken) is null) { await applicationManager.CreateAsync(new OpenIddictApplicationDescriptor { - ClientId = "becf2856-0690-434b-b192-a4032b72067f",//must be guid for MSAL + ClientId = clientId,//must be guid for MSAL ClientType = OpenIddictConstants.ClientTypes.Public, ApplicationType = OpenIddictConstants.ApplicationTypes.Web, - // ClientSecret = "test-secret", Permissions = { OpenIddictConstants.Permissions.Endpoints.Authorization, OpenIddictConstants.Permissions.Endpoints.Token, OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode, OpenIddictConstants.Permissions.GrantTypes.RefreshToken, + OpenIddictConstants.Permissions.GrantTypes.Implicit, OpenIddictConstants.Permissions.ResponseTypes.Code, OpenIddictConstants.Permissions.ResponseTypes.Token, OpenIddictConstants.Permissions.Scopes.Email, OpenIddictConstants.Permissions.Scopes.Profile }, - RedirectUris = { new Uri("https://openidconnect.net/callback"), new Uri("http://localhost:9999") }, + RedirectUris = { new Uri("https://oidcdebugger.com/debug")} }, cancellationToken); From 6d2909cd164cf15edb40ef63c204856ee0484a34 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Thu, 16 May 2024 16:20:34 -0600 Subject: [PATCH 06/14] correct vite proxy to send traces to the collector and not dotnet. --- frontend/vite.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 82a76159b..108f30aa1 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -54,7 +54,7 @@ export default defineConfig({ ] }, proxy: process.env['DockerDev'] ? undefined : { - '/v1/traces': lexboxServer, + '/v1/traces': 'http://localhost:4318', '/api': lexboxServer, '/hg': lexboxServer, '/.well-known': lexboxServer From c348d2b5ea515f5c46670bebe77448e1db0dbc93 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Thu, 16 May 2024 16:21:19 -0600 Subject: [PATCH 07/14] configure the login page to handle return urls --- .../(unauthenticated)/login/+page.svelte | 56 +++++++++++++------ 1 file changed, 40 insertions(+), 16 deletions(-) diff --git a/frontend/src/routes/(unauthenticated)/login/+page.svelte b/frontend/src/routes/(unauthenticated)/login/+page.svelte index 977d7b6e5..52bdd4934 100644 --- a/frontend/src/routes/(unauthenticated)/login/+page.svelte +++ b/frontend/src/routes/(unauthenticated)/login/+page.svelte @@ -1,25 +1,27 @@ @@ -55,7 +79,7 @@
- +
- +
@@ -90,7 +114,7 @@ {$t('register.title')}
{$t('common.or')}
- +
@@ -111,8 +135,8 @@
- - + +
From 493dce6b5edd69936564c286ca3131c47be44b14 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Fri, 17 May 2024 09:39:19 -0600 Subject: [PATCH 08/14] allow application manager to be null in seeding data to allow for case where openId is disabled --- backend/LexData/SeedingData.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/LexData/SeedingData.cs b/backend/LexData/SeedingData.cs index 4a68a3474..674a043eb 100644 --- a/backend/LexData/SeedingData.cs +++ b/backend/LexData/SeedingData.cs @@ -8,7 +8,7 @@ namespace LexData; -public class SeedingData(LexBoxDbContext lexBoxDbContext, IOptions dbConfig, IHostEnvironment environment, IOpenIddictApplicationManager applicationManager) +public class SeedingData(LexBoxDbContext lexBoxDbContext, IOptions dbConfig, IHostEnvironment environment, IOpenIddictApplicationManager? applicationManager = null) { public static readonly Guid TestAdminId = new("cf430ec9-e721-450a-b6a1-9a853212590b"); public static readonly Guid QaAdminId = new("99b00c58-0dc7-4fe4-b6f2-c27b828811e0"); @@ -167,6 +167,7 @@ public async Task SeedDatabase(CancellationToken cancellationToken = default) private async Task SeedOpenId(CancellationToken cancellationToken = default) { + if (applicationManager is null) return; const string clientId = "becf2856-0690-434b-b192-a4032b72067f"; if (await applicationManager.FindByClientIdAsync(clientId, cancellationToken) is null) { From c01cebc8ddefd73eacabc53a62c4addcc9ee493e Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 20 May 2024 11:41:06 -0600 Subject: [PATCH 09/14] require pkce and disable implicit flow, enable oauth to work over http and with proper CORS headers --- backend/LexBoxApi/Auth/AuthKernel.cs | 14 +- .../LexBoxApi/Controllers/LoginController.cs | 134 +++++++++++++++++- backend/LexData/SeedingData.cs | 15 +- frontend/vite.config.ts | 5 +- 4 files changed, 154 insertions(+), 14 deletions(-) diff --git a/backend/LexBoxApi/Auth/AuthKernel.cs b/backend/LexBoxApi/Auth/AuthKernel.cs index 177fe703a..ec982fede 100644 --- a/backend/LexBoxApi/Auth/AuthKernel.cs +++ b/backend/LexBoxApi/Auth/AuthKernel.cs @@ -216,15 +216,16 @@ private static void AddOpenId(IServiceCollection services, IWebHostEnvironment e options.RegisterScopes("openid", "profile", "email"); options.RegisterClaims("aud", "email", "exp", "iss", "iat", "sub", "name"); options.SetAuthorizationEndpointUris("api/login/open-id-auth"); - options.SetTokenEndpointUris("api/connect/token"); + options.SetTokenEndpointUris("api/login/token"); options.SetIntrospectionEndpointUris("api/connect/introspect"); options.SetUserinfoEndpointUris("api/connect/userinfo"); options.Configure(serverOptions => serverOptions.Handlers.Add(ScopeRequestFixer.Descriptor)); options.AllowAuthorizationCodeFlow() - .AllowImplicitFlow()//implicit flow used for response type token .AllowRefreshTokenFlow(); + options.RequireProofKeyForCodeExchange();//best practice to use PKCE with auth code flow and no implicit flow + options.IgnoreResponseTypePermissions(); options.IgnoreScopePermissions(); if (environment.IsDevelopment()) @@ -238,8 +239,13 @@ private static void AddOpenId(IServiceCollection services, IWebHostEnvironment e throw new NotImplementedException("need to implement loading keys from a file"); } - options.UseAspNetCore() - .EnableAuthorizationEndpointPassthrough(); + var aspnetCoreBuilder = options.UseAspNetCore() + .EnableAuthorizationEndpointPassthrough() + .EnableTokenEndpointPassthrough(); + if (environment.IsDevelopment()) + { + aspnetCoreBuilder.DisableTransportSecurityRequirement(); + } }) .AddValidation(options => { diff --git a/backend/LexBoxApi/Controllers/LoginController.cs b/backend/LexBoxApi/Controllers/LoginController.cs index 372ff6d01..e4fcdfb91 100644 --- a/backend/LexBoxApi/Controllers/LoginController.cs +++ b/backend/LexBoxApi/Controllers/LoginController.cs @@ -36,7 +36,9 @@ public class LoginController( EmailService emailService, UserService userService, TurnstileService turnstileService, - ProjectService projectService) + ProjectService projectService, + IOpenIddictApplicationManager applicationManager, + IOpenIddictAuthorizationManager authorizationManager) : ControllerBase { /// @@ -284,6 +286,136 @@ OpenIddictConstants.Claims.Email when return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } + [HttpPost("token")] + [AllowAnonymous] + public async Task Exchange() + { + var request = HttpContext.GetOpenIddictServerRequest() ?? + throw new InvalidOperationException("The OpenID Connect request cannot be retrieved."); + // Retrieve the claims principal stored in the authorization code/refresh token. + var result = await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + var lexAuthUser = result.Succeeded ? LexAuthUser.FromClaimsPrincipal(result.Principal) : null; + if (!result.Succeeded || lexAuthUser is null) + { + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.InvalidGrant, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = + "The token is no longer valid." + })); + } + var requestClientId = request.ClientId; + ArgumentException.ThrowIfNullOrEmpty(requestClientId); + var application = await applicationManager.FindByClientIdAsync(requestClientId) ?? + throw new InvalidOperationException("Details concerning the calling client application cannot be found."); + var userId = lexAuthUser.Id.ToString(); + // Retrieve the permanent authorizations associated with the user and the calling client application. + var authorizations = await authorizationManager.FindAsync( + subject: userId, + client : requestClientId, + status : OpenIddictConstants.Statuses.Valid, + type : OpenIddictConstants.AuthorizationTypes.Permanent, + scopes : request.GetScopes()).ToListAsync(); + + //allow cors response for redirect hosts + var redirectUrisAsync = await applicationManager.GetRedirectUrisAsync(application); + Response.Headers.AccessControlAllowOrigin = redirectUrisAsync.Select(uri => new Uri(uri).GetComponents(UriComponents.SchemeAndServer, UriFormat.Unescaped)).ToArray(); + + // Note: this check is here to ensure a malicious user can't abuse this POST-only endpoint and + // force it to return a valid response without the external authorization. + if (authorizations.Count is 0 && await applicationManager.HasConsentTypeAsync(application, OpenIddictConstants.ConsentTypes.External)) + { + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.ConsentRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = + "The logged in user is not allowed to access this client application." + })); + } + + // Create the claims-based identity that will be used by OpenIddict to generate tokens. + var identity = new ClaimsIdentity( + authenticationType: TokenValidationParameters.DefaultAuthenticationType, + nameType: OpenIddictConstants.Claims.Name, + roleType: OpenIddictConstants.Claims.Role); + + // Add the claims that will be persisted in the tokens. + identity.SetClaim(OpenIddictConstants.Claims.Subject, userId) + .SetClaim(OpenIddictConstants.Claims.Email, lexAuthUser.Email) + .SetClaim(OpenIddictConstants.Claims.Name, lexAuthUser.Name) + .SetClaim(OpenIddictConstants.Claims.Role, lexAuthUser.Role.ToString()); + + // Note: in this sample, the granted scopes match the requested scope + // but you may want to allow the user to uncheck specific scopes. + // For that, simply restrict the list of scopes before calling SetScopes. + identity.SetScopes(request.GetScopes()); + identity.SetAudiences(LexboxAudience.LexboxApi.ToString()); + // identity.SetResources(await _scopeManager.ListResourcesAsync(identity.GetScopes()).ToListAsync()); + + // Automatically create a permanent authorization to avoid requiring explicit consent + // for future authorization or token requests containing the same scopes. + var authorization = authorizations.LastOrDefault(); + authorization ??= await authorizationManager.CreateAsync( + identity: identity, + subject : userId, + client : requestClientId, + type : OpenIddictConstants.AuthorizationTypes.Permanent, + scopes : identity.GetScopes()); + + identity.SetAuthorizationId(await authorizationManager.GetIdAsync(authorization)); + identity.SetDestinations(GetDestinations); + + // Returning a SignInResult will ask OpenIddict to issue the appropriate access/identity tokens. + return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + private static IEnumerable GetDestinations(Claim claim) + { + // Note: by default, claims are NOT automatically included in the access and identity tokens. + // To allow OpenIddict to serialize them, you must attach them a destination, that specifies + // whether they should be included in access tokens, in identity tokens or in both. + + var claimsIdentity = claim.Subject; + ArgumentNullException.ThrowIfNull(claimsIdentity); + switch (claim.Type) + { + case OpenIddictConstants.Claims.Name: + yield return OpenIddictConstants.Destinations.AccessToken; + + if (claimsIdentity.HasScope(OpenIddictConstants.Scopes.Profile)) + yield return OpenIddictConstants.Destinations.IdentityToken; + + yield break; + + case OpenIddictConstants.Claims.Email: + yield return OpenIddictConstants.Destinations.AccessToken; + + if (claimsIdentity.HasScope(OpenIddictConstants.Scopes.Email)) + yield return OpenIddictConstants.Destinations.IdentityToken; + + yield break; + + case OpenIddictConstants.Claims.Role: + yield return OpenIddictConstants.Destinations.AccessToken; + + if (claimsIdentity.HasScope(OpenIddictConstants.Scopes.Roles)) + yield return OpenIddictConstants.Destinations.IdentityToken; + + yield break; + + // Never include the security stamp in the access and identity tokens, as it's a secret value. + case "AspNet.Identity.SecurityStamp": yield break; + + default: + yield return OpenIddictConstants.Destinations.AccessToken; + yield break; + } + } + [HttpPost] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] diff --git a/backend/LexData/SeedingData.cs b/backend/LexData/SeedingData.cs index 674a043eb..631f2357a 100644 --- a/backend/LexData/SeedingData.cs +++ b/backend/LexData/SeedingData.cs @@ -23,12 +23,18 @@ public async Task SeedIfNoUsers(CancellationToken cancellationToken = default) return; } - await SeedDatabase(cancellationToken); + await SeedUserData(cancellationToken); + } + + public async Task SeedDatabase(CancellationToken cancellationToken = default) + { + await SeedOpenId(cancellationToken); + await SeedUserData(cancellationToken); } private const string PwSalt = "password-salt"; - public async Task SeedDatabase(CancellationToken cancellationToken = default) + private async Task SeedUserData(CancellationToken cancellationToken = default) { if (environment.IsProduction()) return; //NOTE: When seeding make sure you provide a constant Id like I have done here, @@ -176,20 +182,19 @@ await applicationManager.CreateAsync(new OpenIddictApplicationDescriptor ClientId = clientId,//must be guid for MSAL ClientType = OpenIddictConstants.ClientTypes.Public, ApplicationType = OpenIddictConstants.ApplicationTypes.Web, + DisplayName = "Oidc Debugger", + ConsentType = OpenIddictConstants.ConsentTypes.Explicit, Permissions = { OpenIddictConstants.Permissions.Endpoints.Authorization, OpenIddictConstants.Permissions.Endpoints.Token, OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode, OpenIddictConstants.Permissions.GrantTypes.RefreshToken, - OpenIddictConstants.Permissions.GrantTypes.Implicit, OpenIddictConstants.Permissions.ResponseTypes.Code, - OpenIddictConstants.Permissions.ResponseTypes.Token, OpenIddictConstants.Permissions.Scopes.Email, OpenIddictConstants.Permissions.Scopes.Profile }, RedirectUris = { new Uri("https://oidcdebugger.com/debug")} - }, cancellationToken); } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 108f30aa1..c91620b11 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -34,7 +34,7 @@ export default defineConfig({ codegen(gqlOptions), precompileIntl('src/lib/i18n/locales'), sveltekit(), - basicSsl() + // basicSsl() ], optimizeDeps: { }, @@ -44,9 +44,6 @@ export default defineConfig({ server: { port: 3000, host: exposeServer, - https: { - - }, strictPort: true, fs: { allow: [ From a3186397146d117a418d58e0a0e57851c69f055c Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 20 May 2024 13:03:06 -0600 Subject: [PATCH 10/14] create approval flow for oauth login --- .../LexBoxApi/Controllers/LoginController.cs | 199 +++++++++++------- backend/LexData/SeedingData.cs | 1 + .../(authenticated)/authorize/+page.svelte | 18 ++ .../routes/(authenticated)/authorize/+page.ts | 9 + 4 files changed, 148 insertions(+), 79 deletions(-) create mode 100644 frontend/src/routes/(authenticated)/authorize/+page.svelte create mode 100644 frontend/src/routes/(authenticated)/authorize/+page.ts diff --git a/backend/LexBoxApi/Controllers/LoginController.cs b/backend/LexBoxApi/Controllers/LoginController.cs index e4fcdfb91..06cbd85c8 100644 --- a/backend/LexBoxApi/Controllers/LoginController.cs +++ b/backend/LexBoxApi/Controllers/LoginController.cs @@ -17,13 +17,16 @@ using Microsoft.AspNetCore.Http; using LexCore.Entities; using System.Security.Claims; +using System.Text.Json; using Microsoft.AspNetCore; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Google; using Microsoft.Extensions.Primitives; using Microsoft.IdentityModel.Tokens; using OpenIddict.Abstractions; +using OpenIddict.EntityFrameworkCore.Models; using OpenIddict.Server.AspNetCore; +using Org.BouncyCastle.Ocsp; namespace LexBoxApi.Controllers; @@ -171,6 +174,8 @@ public async Task> VerifyEmail( [HttpGet("open-id-auth")] [HttpPost("open-id-auth")] + [ProducesResponseType(400)] + [ProducesDefaultResponseType] public async Task Authorize(string? returnUrl = null) { var request = HttpContext.GetOpenIddictServerRequest(); @@ -179,13 +184,22 @@ public async Task Authorize(string? returnUrl = null) return BadRequest(); } + if (IsAcceptRequest()) + { + var lexAuthUser1 = loggedInContext.User; + var request1 = HttpContext.GetOpenIddictServerRequest() ?? + throw new InvalidOperationException("The OpenID Connect request cannot be retrieved."); + return await FinishSignIn(lexAuthUser1, request1); + } + // Retrieve the user principal stored in the authentication cookie. - // If a max_age parameter was provided, ensure that the cookie is not too old. // If the user principal can't be extracted or the cookie is too old, redirect the user to the login page. var result = await HttpContext.AuthenticateAsync(); - if (!result.Succeeded || (request.MaxAge != null && result.Properties?.IssuedUtc != null && - DateTimeOffset.UtcNow - result.Properties.IssuedUtc > - TimeSpan.FromSeconds(request.MaxAge.Value))) + var lexAuthUser = result.Succeeded ? LexAuthUser.FromClaimsPrincipal(result.Principal) : null; + if (!result.Succeeded || + lexAuthUser is null || + request.HasPrompt(OpenIddictConstants.Prompts.Login) || + IsExpired(request, result)) { // If the client application requested promptless authentication, // return an error indicating that the user is not logged in. @@ -202,19 +216,6 @@ public async Task Authorize(string? returnUrl = null) })); } - return Challenge( - authenticationSchemes: CookieAuthenticationDefaults.AuthenticationScheme, - properties: new AuthenticationProperties - { - RedirectUri = Request.PathBase + Request.Path + QueryString.Create( - Request.HasFormContentType ? [.. Request.Form] : [.. Request.Query]) - }); - } - - // If prompt=login was specified by the client application, - // immediately return the user agent to the login page. - if (request.HasPrompt(OpenIddictConstants.Prompts.Login)) - { // To avoid endless login -> authorization redirects, the prompt=login flag // is removed from the authorization request payload before redirecting the user. var prompt = string.Join(" ", request.GetPrompts().Remove(OpenIddictConstants.Prompts.Login)); @@ -233,57 +234,83 @@ public async Task Authorize(string? returnUrl = null) }); } - //todo create identity from db - // Create the claims-based identity that will be used by OpenIddict to generate tokens. - var identity = new ClaimsIdentity(result.Principal!.Claims, - authenticationType: TokenValidationParameters.DefaultAuthenticationType, - nameType: OpenIddictConstants.Claims.Name, - roleType: OpenIddictConstants.Claims.Role); - identity.SetScopes(new[] - { - OpenIddictConstants.Scopes.OfflineAccess, - OpenIddictConstants.Scopes.OpenId, - OpenIddictConstants.Scopes.Email, - OpenIddictConstants.Scopes.Profile - }.Intersect(request.GetScopes())); - identity.SetAudiences(LexboxAudience.LexboxApi.ToString()); - identity.SetDestinations(claim => claim.Type switch + var userId = lexAuthUser.Id.ToString(); + var requestClientId = request.ClientId; + ArgumentException.ThrowIfNullOrEmpty(requestClientId); + var application = await applicationManager.FindByClientIdAsync(requestClientId) ?? + throw new InvalidOperationException( + "Details concerning the calling client application cannot be found."); + var applicationId = await applicationManager.GetIdAsync(application) ?? + throw new InvalidOperationException("The calling client application could not be found."); + + // Retrieve the permanent authorizations associated with the user and the calling client application. + var authorizations = await authorizationManager.FindAsync( + subject: userId, + client: applicationId, + status: OpenIddictConstants.Statuses.Valid, + type: OpenIddictConstants.AuthorizationTypes.Permanent, + scopes: request.GetScopes()).ToListAsync(); + + switch (await applicationManager.GetConsentTypeAsync(application)) { - // Note: always include acr and auth_time in the identity token as they must be flowed - // from the authorization endpoint to the identity token returned from the token endpoint. - OpenIddictConstants.Claims.AuthenticationContextReference or - OpenIddictConstants.Claims.AuthenticationTime - => ImmutableArray.Create(OpenIddictConstants.Destinations.IdentityToken), - - // Note: when an authorization code or access token is returned, don't add the profile, email, - // phone and address claims to the identity tokens as they are returned from the userinfo endpoint. - OpenIddictConstants.Claims.Subject or - OpenIddictConstants.Claims.Name or - OpenIddictConstants.Claims.Locale or - OpenIddictConstants.Claims.UpdatedAt when - identity.HasScope(OpenIddictConstants.Scopes.Profile) - //todo consider if this should be enabled or not. It prevents the name from ending up in the ID token, but I guess that's supposed to be handled with the userinfo endpoint - // && !request.HasResponseType(OpenIddictConstants.ResponseTypes.Code) - // && !request.HasResponseType(OpenIddictConstants.ResponseTypes.Token) - => - [ - OpenIddictConstants.Destinations.AccessToken, - OpenIddictConstants.Destinations.IdentityToken - ], - - OpenIddictConstants.Claims.Email when - identity.HasScope(OpenIddictConstants.Scopes.Email) && - !request.HasResponseType(OpenIddictConstants.ResponseTypes.Code) && - !request.HasResponseType(OpenIddictConstants.ResponseTypes.Token) => - [ - OpenIddictConstants.Destinations.AccessToken, - OpenIddictConstants.Destinations.IdentityToken - ], - - _ => [OpenIddictConstants.Destinations.AccessToken] - }); + // If the consent is implicit or if an authorization was found, + // return an authorization response without displaying the consent form. + case OpenIddictConstants.ConsentTypes.Implicit: + case OpenIddictConstants.ConsentTypes.External when authorizations.Count is not 0: + case OpenIddictConstants.ConsentTypes.Explicit when authorizations.Count is not 0 && !request.HasPrompt(OpenIddictConstants.Prompts.Consent): - return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + return await FinishSignIn(lexAuthUser, request, applicationId, authorizations); + + // If the consent is external (e.g when authorizations are granted by a sysadmin), + // immediately return an error if no authorization can be found in the database. + case OpenIddictConstants.ConsentTypes.External when authorizations.Count is 0: + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.ConsentRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = + "The logged in user is not allowed to access this client application." + })); + + // At this point, no authorization was found in the database and an error must be returned + // if the client application specified prompt=none in the authorization request. + case OpenIddictConstants.ConsentTypes.Explicit when request.HasPrompt(OpenIddictConstants.Prompts.None): + case OpenIddictConstants.ConsentTypes.Systematic when request.HasPrompt(OpenIddictConstants.Prompts.None): + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.ConsentRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = + "Interactive user consent is required." + })); + + // In every other case, send user to consent page + default: + var parameters = Request.HasFormContentType + ? Request.Form.ToList() + : Request.Query.ToList(); + var data = JsonSerializer.Serialize(parameters.ToDictionary(pair => pair.Key, pair => pair.Value.ToString())); + var queryString = new QueryString() + .Add("appName", await applicationManager.GetDisplayNameAsync(application) ?? "Unknown app") + .Add("scope", request.Scope ?? "") + .Add("postback", data); + return Redirect($"/authorize{queryString.Value}"); + } + } + + private static bool IsExpired(OpenIddictRequest request, AuthenticateResult result) + { + // If a max_age parameter was provided, ensure that the cookie is not too old. + return (request.MaxAge != null && result.Properties?.IssuedUtc != null && + DateTimeOffset.UtcNow - result.Properties.IssuedUtc > + TimeSpan.FromSeconds(request.MaxAge.Value)); + } + + private bool IsAcceptRequest() + { + return Request.Method == "POST" && Request.Form.ContainsKey("submit.accept") && User.Identity?.IsAuthenticated == true; } [HttpPost("token")] @@ -306,26 +333,35 @@ public async Task Exchange() "The token is no longer valid." })); } + + return await FinishSignIn(lexAuthUser, request); + } + + private async Task FinishSignIn(LexAuthUser lexAuthUser, OpenIddictRequest request) + { var requestClientId = request.ClientId; ArgumentException.ThrowIfNullOrEmpty(requestClientId); var application = await applicationManager.FindByClientIdAsync(requestClientId) ?? - throw new InvalidOperationException("Details concerning the calling client application cannot be found."); - var userId = lexAuthUser.Id.ToString(); + throw new InvalidOperationException( + "Details concerning the calling client application cannot be found."); // Retrieve the permanent authorizations associated with the user and the calling client application. + var applicationId = await applicationManager.GetIdAsync(application) ?? throw new InvalidOperationException("The calling client application could not be found."); var authorizations = await authorizationManager.FindAsync( - subject: userId, - client : requestClientId, - status : OpenIddictConstants.Statuses.Valid, - type : OpenIddictConstants.AuthorizationTypes.Permanent, - scopes : request.GetScopes()).ToListAsync(); + subject: lexAuthUser.Id.ToString(), + client: applicationId, + status: OpenIddictConstants.Statuses.Valid, + type: OpenIddictConstants.AuthorizationTypes.Permanent, + scopes: request.GetScopes()).ToListAsync(); //allow cors response for redirect hosts var redirectUrisAsync = await applicationManager.GetRedirectUrisAsync(application); - Response.Headers.AccessControlAllowOrigin = redirectUrisAsync.Select(uri => new Uri(uri).GetComponents(UriComponents.SchemeAndServer, UriFormat.Unescaped)).ToArray(); + Response.Headers.AccessControlAllowOrigin = redirectUrisAsync + .Select(uri => new Uri(uri).GetComponents(UriComponents.SchemeAndServer, UriFormat.Unescaped)).ToArray(); // Note: this check is here to ensure a malicious user can't abuse this POST-only endpoint and // force it to return a valid response without the external authorization. - if (authorizations.Count is 0 && await applicationManager.HasConsentTypeAsync(application, OpenIddictConstants.ConsentTypes.External)) + if (authorizations.Count is 0 && + await applicationManager.HasConsentTypeAsync(application, OpenIddictConstants.ConsentTypes.External)) { return Forbid( authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, @@ -337,6 +373,11 @@ public async Task Exchange() })); } + return await FinishSignIn(lexAuthUser, request, applicationId, authorizations); + } + private async Task FinishSignIn(LexAuthUser lexAuthUser, OpenIddictRequest request, string applicationId, List authorizations) + { + var userId = lexAuthUser.Id.ToString(); // Create the claims-based identity that will be used by OpenIddict to generate tokens. var identity = new ClaimsIdentity( authenticationType: TokenValidationParameters.DefaultAuthenticationType, @@ -345,9 +386,9 @@ public async Task Exchange() // Add the claims that will be persisted in the tokens. identity.SetClaim(OpenIddictConstants.Claims.Subject, userId) - .SetClaim(OpenIddictConstants.Claims.Email, lexAuthUser.Email) - .SetClaim(OpenIddictConstants.Claims.Name, lexAuthUser.Name) - .SetClaim(OpenIddictConstants.Claims.Role, lexAuthUser.Role.ToString()); + .SetClaim(OpenIddictConstants.Claims.Email, lexAuthUser.Email) + .SetClaim(OpenIddictConstants.Claims.Name, lexAuthUser.Name) + .SetClaim(OpenIddictConstants.Claims.Role, lexAuthUser.Role.ToString()); // Note: in this sample, the granted scopes match the requested scope // but you may want to allow the user to uncheck specific scopes. @@ -362,7 +403,7 @@ public async Task Exchange() authorization ??= await authorizationManager.CreateAsync( identity: identity, subject : userId, - client : requestClientId, + client : applicationId, type : OpenIddictConstants.AuthorizationTypes.Permanent, scopes : identity.GetScopes()); diff --git a/backend/LexData/SeedingData.cs b/backend/LexData/SeedingData.cs index 631f2357a..b7523c10b 100644 --- a/backend/LexData/SeedingData.cs +++ b/backend/LexData/SeedingData.cs @@ -183,6 +183,7 @@ await applicationManager.CreateAsync(new OpenIddictApplicationDescriptor ClientType = OpenIddictConstants.ClientTypes.Public, ApplicationType = OpenIddictConstants.ApplicationTypes.Web, DisplayName = "Oidc Debugger", + //explicit requires the user to consent, Implicit does not, External requires an admin to approve, not currently supported ConsentType = OpenIddictConstants.ConsentTypes.Explicit, Permissions = { diff --git a/frontend/src/routes/(authenticated)/authorize/+page.svelte b/frontend/src/routes/(authenticated)/authorize/+page.svelte new file mode 100644 index 000000000..5c2feda77 --- /dev/null +++ b/frontend/src/routes/(authenticated)/authorize/+page.svelte @@ -0,0 +1,18 @@ + + +
+ +

Do you want to permit "{data.appName}" to access your data? (scopes: {data.scope})

+ {#each Object.entries(data.postback) as [key, value]} + + + {/each} + + +
+ diff --git a/frontend/src/routes/(authenticated)/authorize/+page.ts b/frontend/src/routes/(authenticated)/authorize/+page.ts new file mode 100644 index 000000000..e697d0d16 --- /dev/null +++ b/frontend/src/routes/(authenticated)/authorize/+page.ts @@ -0,0 +1,9 @@ +import type { PageLoadEvent } from './$types'; + +export function load(event: PageLoadEvent) { + return { + appName: event.url.searchParams.get('appName') as string, + scope: event.url.searchParams.get('scope'), + postback: JSON.parse(event.url.searchParams.get('postback') ?? '{}') as Record + }; +} From 22e88647d893bdfdc801d938fa25b761e0667cb9 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 20 May 2024 13:25:25 -0600 Subject: [PATCH 11/14] correct vite proxy so https isn't used incorrectly when the backend redirects somewhere --- frontend/vite.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index c91620b11..051c174e1 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -13,7 +13,7 @@ import { sveltekit } from '@sveltejs/kit/vite'; const exposeServer = false; const lexboxServer: ProxyOptions = { - target: 'https://localhost:7075', + target: 'http://localhost:5158', secure: false }; From d4940a613df1c68f2eb9f63d821de2147c69e980 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Tue, 21 May 2024 10:16:42 -0600 Subject: [PATCH 12/14] pass redirect uri along via google login, fix bug where redirect url was always null in `CompleteGoogleLogin` due to using the wrong property. --- backend/LexBoxApi/Auth/AuthKernel.cs | 3 ++- frontend/src/lib/components/SigninWithGoogleButton.svelte | 2 +- frontend/src/routes/(unauthenticated)/login/+page.svelte | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/backend/LexBoxApi/Auth/AuthKernel.cs b/backend/LexBoxApi/Auth/AuthKernel.cs index ec982fede..9fadbe6fd 100644 --- a/backend/LexBoxApi/Auth/AuthKernel.cs +++ b/backend/LexBoxApi/Auth/AuthKernel.cs @@ -162,7 +162,8 @@ public static void AddLexBoxAuth(IServiceCollection services, context.HandleResponse(); var loginController = context.HttpContext.RequestServices.GetRequiredService(); loginController.ControllerContext.HttpContext = context.HttpContext; - var redirectTo = await loginController.CompleteGoogleLogin(context.Principal, context.Properties?.RedirectUri); + //using context.ReturnUri and not context.Properties.RedirectUri because the latter is null + var redirectTo = await loginController.CompleteGoogleLogin(context.Principal, context.ReturnUri); context.HttpContext.Response.Redirect(redirectTo); }; }); diff --git a/frontend/src/lib/components/SigninWithGoogleButton.svelte b/frontend/src/lib/components/SigninWithGoogleButton.svelte index 5be17e2da..fa341ef43 100644 --- a/frontend/src/lib/components/SigninWithGoogleButton.svelte +++ b/frontend/src/lib/components/SigninWithGoogleButton.svelte @@ -12,7 +12,7 @@ - commented out overflow: hidden, because it's unnecessary and makes text ugly on click (due to .btn) - added media query for dark mode using colors from the guidelines --> - +
From dd593df0f9893da01e44bbf7197612a02843fbe6 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Tue, 28 May 2024 15:03:50 +0200 Subject: [PATCH 13/14] Redesign authorize page --- frontend/src/lib/icons/Icon.svelte | 2 +- frontend/src/lib/layout/Layout.svelte | 56 +++++++------ .../(authenticated)/authorize/+layout@.svelte | 7 ++ .../(authenticated)/authorize/+page.svelte | 79 ++++++++++++++++--- 4 files changed, 105 insertions(+), 39 deletions(-) create mode 100644 frontend/src/routes/(authenticated)/authorize/+layout@.svelte diff --git a/frontend/src/lib/icons/Icon.svelte b/frontend/src/lib/icons/Icon.svelte index 34972528e..435f41abc 100644 --- a/frontend/src/lib/icons/Icon.svelte +++ b/frontend/src/lib/icons/Icon.svelte @@ -1,6 +1,6 @@ + + + + diff --git a/frontend/src/routes/(authenticated)/authorize/+page.svelte b/frontend/src/routes/(authenticated)/authorize/+page.svelte index 5c2feda77..645e659de 100644 --- a/frontend/src/routes/(authenticated)/authorize/+page.svelte +++ b/frontend/src/routes/(authenticated)/authorize/+page.svelte @@ -1,18 +1,73 @@  -
- -

Do you want to permit "{data.appName}" to access your data? (scopes: {data.scope})

- {#each Object.entries(data.postback) as [key, value]} - - - {/each} - - -
- +
+
+
+
+
+
+ +
+

+ Authorize "{data.appName}" +

+
+ {data.appName} wants to access your account. +
+
+
+
+ {#each data.scope?.split(' ') ?? [] as scope} + {#if scope === 'profile'} +
+ +
+
Personal user data
+
Name, email address and/or username (read-only)
+
+
+ {/if} + {#if scope === 'profile'} +
+ +
+
Projects
+
Project membership and roles (read-only)
+
+
+ {/if} + {/each} +
+
+
+ {#each Object.entries(data.postback) as [key, value]} + + + {/each} +
+
+ {data.appName} is trusted by Language Depot + +
+
+ + +
+
+
+
+
From aabb6516b182f0e048d8c01f0cd005189482280c Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Tue, 28 May 2024 10:18:04 -0600 Subject: [PATCH 14/14] extract oauth code out of LoginController.cs and into OauthController.cs, revert some changes made to vite.config.ts --- backend/LexBoxApi/Auth/AuthKernel.cs | 11 +- .../LexBoxApi/Controllers/LoginController.cs | 303 +---------------- .../LexBoxApi/Controllers/OauthController.cs | 307 ++++++++++++++++++ .../(authenticated)/authorize/+page.svelte | 2 +- frontend/vite.config.ts | 2 +- 5 files changed, 316 insertions(+), 309 deletions(-) create mode 100644 backend/LexBoxApi/Controllers/OauthController.cs diff --git a/backend/LexBoxApi/Auth/AuthKernel.cs b/backend/LexBoxApi/Auth/AuthKernel.cs index 9fadbe6fd..38fae368b 100644 --- a/backend/LexBoxApi/Auth/AuthKernel.cs +++ b/backend/LexBoxApi/Auth/AuthKernel.cs @@ -1,3 +1,4 @@ +using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; using System.Net.Http.Headers; using System.Text; @@ -204,7 +205,6 @@ public static void AddLexBoxAuth(IServiceCollection services, private static void AddOpenId(IServiceCollection services, IWebHostEnvironment environment) { services.Add(ScopeRequestFixer.Descriptor.ServiceDescriptor); - //openid server services.AddOpenIddict() .AddCore(options => @@ -215,11 +215,12 @@ private static void AddOpenId(IServiceCollection services, IWebHostEnvironment e .AddServer(options => { options.RegisterScopes("openid", "profile", "email"); + //todo add application claims options.RegisterClaims("aud", "email", "exp", "iss", "iat", "sub", "name"); - options.SetAuthorizationEndpointUris("api/login/open-id-auth"); - options.SetTokenEndpointUris("api/login/token"); - options.SetIntrospectionEndpointUris("api/connect/introspect"); - options.SetUserinfoEndpointUris("api/connect/userinfo"); + options.SetAuthorizationEndpointUris("api/oauth/open-id-auth"); + options.SetTokenEndpointUris("api/oauth/token"); + options.SetIntrospectionEndpointUris("api/oauth/introspect"); + options.SetUserinfoEndpointUris("api/oauth/userinfo"); options.Configure(serverOptions => serverOptions.Handlers.Add(ScopeRequestFixer.Descriptor)); options.AllowAuthorizationCodeFlow() diff --git a/backend/LexBoxApi/Controllers/LoginController.cs b/backend/LexBoxApi/Controllers/LoginController.cs index 06cbd85c8..34466ab10 100644 --- a/backend/LexBoxApi/Controllers/LoginController.cs +++ b/backend/LexBoxApi/Controllers/LoginController.cs @@ -1,4 +1,3 @@ -using System.Collections.Immutable; using System.ComponentModel.DataAnnotations; using LexBoxApi.Auth; using LexBoxApi.Auth.Attributes; @@ -9,24 +8,11 @@ using LexCore.Auth; using LexData; using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.EntityFrameworkCore; -using Microsoft.AspNetCore.Http; -using LexCore.Entities; using System.Security.Claims; -using System.Text.Json; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Google; -using Microsoft.Extensions.Primitives; -using Microsoft.IdentityModel.Tokens; -using OpenIddict.Abstractions; -using OpenIddict.EntityFrameworkCore.Models; -using OpenIddict.Server.AspNetCore; -using Org.BouncyCastle.Ocsp; namespace LexBoxApi.Controllers; @@ -38,10 +24,7 @@ public class LoginController( LoggedInContext loggedInContext, EmailService emailService, UserService userService, - TurnstileService turnstileService, - ProjectService projectService, - IOpenIddictApplicationManager applicationManager, - IOpenIddictAuthorizationManager authorizationManager) + TurnstileService turnstileService) : ControllerBase { /// @@ -172,290 +155,6 @@ public async Task> VerifyEmail( return Redirect(returnTo); } - [HttpGet("open-id-auth")] - [HttpPost("open-id-auth")] - [ProducesResponseType(400)] - [ProducesDefaultResponseType] - public async Task Authorize(string? returnUrl = null) - { - var request = HttpContext.GetOpenIddictServerRequest(); - if (request is null) - { - return BadRequest(); - } - - if (IsAcceptRequest()) - { - var lexAuthUser1 = loggedInContext.User; - var request1 = HttpContext.GetOpenIddictServerRequest() ?? - throw new InvalidOperationException("The OpenID Connect request cannot be retrieved."); - return await FinishSignIn(lexAuthUser1, request1); - } - - // Retrieve the user principal stored in the authentication cookie. - // If the user principal can't be extracted or the cookie is too old, redirect the user to the login page. - var result = await HttpContext.AuthenticateAsync(); - var lexAuthUser = result.Succeeded ? LexAuthUser.FromClaimsPrincipal(result.Principal) : null; - if (!result.Succeeded || - lexAuthUser is null || - request.HasPrompt(OpenIddictConstants.Prompts.Login) || - IsExpired(request, result)) - { - // If the client application requested promptless authentication, - // return an error indicating that the user is not logged in. - if (request.HasPrompt(OpenIddictConstants.Prompts.None)) - { - return Forbid( - authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, - properties: new AuthenticationProperties(new Dictionary - { - [OpenIddictServerAspNetCoreConstants.Properties.Error] = - OpenIddictConstants.Errors.LoginRequired, - [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = - "The user is not logged in." - })); - } - - // To avoid endless login -> authorization redirects, the prompt=login flag - // is removed from the authorization request payload before redirecting the user. - var prompt = string.Join(" ", request.GetPrompts().Remove(OpenIddictConstants.Prompts.Login)); - - var parameters = Request.HasFormContentType - ? Request.Form.Where(parameter => parameter.Key != OpenIddictConstants.Parameters.Prompt).ToList() - : Request.Query.Where(parameter => parameter.Key != OpenIddictConstants.Parameters.Prompt).ToList(); - - parameters.Add(KeyValuePair.Create(OpenIddictConstants.Parameters.Prompt, new StringValues(prompt))); - - return Challenge( - authenticationSchemes: CookieAuthenticationDefaults.AuthenticationScheme, - properties: new AuthenticationProperties - { - RedirectUri = Request.PathBase + Request.Path + QueryString.Create(parameters) - }); - } - - var userId = lexAuthUser.Id.ToString(); - var requestClientId = request.ClientId; - ArgumentException.ThrowIfNullOrEmpty(requestClientId); - var application = await applicationManager.FindByClientIdAsync(requestClientId) ?? - throw new InvalidOperationException( - "Details concerning the calling client application cannot be found."); - var applicationId = await applicationManager.GetIdAsync(application) ?? - throw new InvalidOperationException("The calling client application could not be found."); - - // Retrieve the permanent authorizations associated with the user and the calling client application. - var authorizations = await authorizationManager.FindAsync( - subject: userId, - client: applicationId, - status: OpenIddictConstants.Statuses.Valid, - type: OpenIddictConstants.AuthorizationTypes.Permanent, - scopes: request.GetScopes()).ToListAsync(); - - switch (await applicationManager.GetConsentTypeAsync(application)) - { - // If the consent is implicit or if an authorization was found, - // return an authorization response without displaying the consent form. - case OpenIddictConstants.ConsentTypes.Implicit: - case OpenIddictConstants.ConsentTypes.External when authorizations.Count is not 0: - case OpenIddictConstants.ConsentTypes.Explicit when authorizations.Count is not 0 && !request.HasPrompt(OpenIddictConstants.Prompts.Consent): - - return await FinishSignIn(lexAuthUser, request, applicationId, authorizations); - - // If the consent is external (e.g when authorizations are granted by a sysadmin), - // immediately return an error if no authorization can be found in the database. - case OpenIddictConstants.ConsentTypes.External when authorizations.Count is 0: - return Forbid( - authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, - properties: new AuthenticationProperties(new Dictionary - { - [OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.ConsentRequired, - [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = - "The logged in user is not allowed to access this client application." - })); - - // At this point, no authorization was found in the database and an error must be returned - // if the client application specified prompt=none in the authorization request. - case OpenIddictConstants.ConsentTypes.Explicit when request.HasPrompt(OpenIddictConstants.Prompts.None): - case OpenIddictConstants.ConsentTypes.Systematic when request.HasPrompt(OpenIddictConstants.Prompts.None): - return Forbid( - authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, - properties: new AuthenticationProperties(new Dictionary - { - [OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.ConsentRequired, - [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = - "Interactive user consent is required." - })); - - // In every other case, send user to consent page - default: - var parameters = Request.HasFormContentType - ? Request.Form.ToList() - : Request.Query.ToList(); - var data = JsonSerializer.Serialize(parameters.ToDictionary(pair => pair.Key, pair => pair.Value.ToString())); - var queryString = new QueryString() - .Add("appName", await applicationManager.GetDisplayNameAsync(application) ?? "Unknown app") - .Add("scope", request.Scope ?? "") - .Add("postback", data); - return Redirect($"/authorize{queryString.Value}"); - } - } - - private static bool IsExpired(OpenIddictRequest request, AuthenticateResult result) - { - // If a max_age parameter was provided, ensure that the cookie is not too old. - return (request.MaxAge != null && result.Properties?.IssuedUtc != null && - DateTimeOffset.UtcNow - result.Properties.IssuedUtc > - TimeSpan.FromSeconds(request.MaxAge.Value)); - } - - private bool IsAcceptRequest() - { - return Request.Method == "POST" && Request.Form.ContainsKey("submit.accept") && User.Identity?.IsAuthenticated == true; - } - - [HttpPost("token")] - [AllowAnonymous] - public async Task Exchange() - { - var request = HttpContext.GetOpenIddictServerRequest() ?? - throw new InvalidOperationException("The OpenID Connect request cannot be retrieved."); - // Retrieve the claims principal stored in the authorization code/refresh token. - var result = await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); - var lexAuthUser = result.Succeeded ? LexAuthUser.FromClaimsPrincipal(result.Principal) : null; - if (!result.Succeeded || lexAuthUser is null) - { - return Forbid( - authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, - properties: new AuthenticationProperties(new Dictionary - { - [OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.InvalidGrant, - [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = - "The token is no longer valid." - })); - } - - return await FinishSignIn(lexAuthUser, request); - } - - private async Task FinishSignIn(LexAuthUser lexAuthUser, OpenIddictRequest request) - { - var requestClientId = request.ClientId; - ArgumentException.ThrowIfNullOrEmpty(requestClientId); - var application = await applicationManager.FindByClientIdAsync(requestClientId) ?? - throw new InvalidOperationException( - "Details concerning the calling client application cannot be found."); - // Retrieve the permanent authorizations associated with the user and the calling client application. - var applicationId = await applicationManager.GetIdAsync(application) ?? throw new InvalidOperationException("The calling client application could not be found."); - var authorizations = await authorizationManager.FindAsync( - subject: lexAuthUser.Id.ToString(), - client: applicationId, - status: OpenIddictConstants.Statuses.Valid, - type: OpenIddictConstants.AuthorizationTypes.Permanent, - scopes: request.GetScopes()).ToListAsync(); - - //allow cors response for redirect hosts - var redirectUrisAsync = await applicationManager.GetRedirectUrisAsync(application); - Response.Headers.AccessControlAllowOrigin = redirectUrisAsync - .Select(uri => new Uri(uri).GetComponents(UriComponents.SchemeAndServer, UriFormat.Unescaped)).ToArray(); - - // Note: this check is here to ensure a malicious user can't abuse this POST-only endpoint and - // force it to return a valid response without the external authorization. - if (authorizations.Count is 0 && - await applicationManager.HasConsentTypeAsync(application, OpenIddictConstants.ConsentTypes.External)) - { - return Forbid( - authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, - properties: new AuthenticationProperties(new Dictionary - { - [OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.ConsentRequired, - [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = - "The logged in user is not allowed to access this client application." - })); - } - - return await FinishSignIn(lexAuthUser, request, applicationId, authorizations); - } - private async Task FinishSignIn(LexAuthUser lexAuthUser, OpenIddictRequest request, string applicationId, List authorizations) - { - var userId = lexAuthUser.Id.ToString(); - // Create the claims-based identity that will be used by OpenIddict to generate tokens. - var identity = new ClaimsIdentity( - authenticationType: TokenValidationParameters.DefaultAuthenticationType, - nameType: OpenIddictConstants.Claims.Name, - roleType: OpenIddictConstants.Claims.Role); - - // Add the claims that will be persisted in the tokens. - identity.SetClaim(OpenIddictConstants.Claims.Subject, userId) - .SetClaim(OpenIddictConstants.Claims.Email, lexAuthUser.Email) - .SetClaim(OpenIddictConstants.Claims.Name, lexAuthUser.Name) - .SetClaim(OpenIddictConstants.Claims.Role, lexAuthUser.Role.ToString()); - - // Note: in this sample, the granted scopes match the requested scope - // but you may want to allow the user to uncheck specific scopes. - // For that, simply restrict the list of scopes before calling SetScopes. - identity.SetScopes(request.GetScopes()); - identity.SetAudiences(LexboxAudience.LexboxApi.ToString()); - // identity.SetResources(await _scopeManager.ListResourcesAsync(identity.GetScopes()).ToListAsync()); - - // Automatically create a permanent authorization to avoid requiring explicit consent - // for future authorization or token requests containing the same scopes. - var authorization = authorizations.LastOrDefault(); - authorization ??= await authorizationManager.CreateAsync( - identity: identity, - subject : userId, - client : applicationId, - type : OpenIddictConstants.AuthorizationTypes.Permanent, - scopes : identity.GetScopes()); - - identity.SetAuthorizationId(await authorizationManager.GetIdAsync(authorization)); - identity.SetDestinations(GetDestinations); - - // Returning a SignInResult will ask OpenIddict to issue the appropriate access/identity tokens. - return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); - } - - private static IEnumerable GetDestinations(Claim claim) - { - // Note: by default, claims are NOT automatically included in the access and identity tokens. - // To allow OpenIddict to serialize them, you must attach them a destination, that specifies - // whether they should be included in access tokens, in identity tokens or in both. - - var claimsIdentity = claim.Subject; - ArgumentNullException.ThrowIfNull(claimsIdentity); - switch (claim.Type) - { - case OpenIddictConstants.Claims.Name: - yield return OpenIddictConstants.Destinations.AccessToken; - - if (claimsIdentity.HasScope(OpenIddictConstants.Scopes.Profile)) - yield return OpenIddictConstants.Destinations.IdentityToken; - - yield break; - - case OpenIddictConstants.Claims.Email: - yield return OpenIddictConstants.Destinations.AccessToken; - - if (claimsIdentity.HasScope(OpenIddictConstants.Scopes.Email)) - yield return OpenIddictConstants.Destinations.IdentityToken; - - yield break; - - case OpenIddictConstants.Claims.Role: - yield return OpenIddictConstants.Destinations.AccessToken; - - if (claimsIdentity.HasScope(OpenIddictConstants.Scopes.Roles)) - yield return OpenIddictConstants.Destinations.IdentityToken; - - yield break; - - // Never include the security stamp in the access and identity tokens, as it's a secret value. - case "AspNet.Identity.SecurityStamp": yield break; - - default: - yield return OpenIddictConstants.Destinations.AccessToken; - yield break; - } - } [HttpPost] [ProducesResponseType(StatusCodes.Status200OK)] diff --git a/backend/LexBoxApi/Controllers/OauthController.cs b/backend/LexBoxApi/Controllers/OauthController.cs new file mode 100644 index 000000000..b7024a295 --- /dev/null +++ b/backend/LexBoxApi/Controllers/OauthController.cs @@ -0,0 +1,307 @@ +using System.Security.Claims; +using System.Text.Json; +using LexBoxApi.Auth; +using LexCore.Auth; +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Primitives; +using Microsoft.IdentityModel.Tokens; +using OpenIddict.Abstractions; +using OpenIddict.Server.AspNetCore; + +namespace LexBoxApi.Controllers; + +[ApiController] +[Route("/api/oauth")] +public class OauthController( + LoggedInContext loggedInContext, + IOpenIddictApplicationManager applicationManager, + IOpenIddictAuthorizationManager authorizationManager +) : ControllerBase +{ + + [HttpGet("open-id-auth")] + [HttpPost("open-id-auth")] + [ProducesResponseType(400)] + [ProducesDefaultResponseType] + public async Task Authorize() + { + var request = HttpContext.GetOpenIddictServerRequest(); + if (request is null) + { + return BadRequest(); + } + + if (IsAcceptRequest()) + { + return await FinishSignIn(loggedInContext.User, request); + } + + // Retrieve the user principal stored in the authentication cookie. + // If the user principal can't be extracted or the cookie is too old, redirect the user to the login page. + var result = await HttpContext.AuthenticateAsync(); + var lexAuthUser = result.Succeeded ? LexAuthUser.FromClaimsPrincipal(result.Principal) : null; + if (!result.Succeeded || + lexAuthUser is null || + request.HasPrompt(OpenIddictConstants.Prompts.Login) || + IsExpired(request, result)) + { + // If the client application requested promptless authentication, + // return an error indicating that the user is not logged in. + if (request.HasPrompt(OpenIddictConstants.Prompts.None)) + { + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = + OpenIddictConstants.Errors.LoginRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = + "The user is not logged in." + })); + } + + // To avoid endless login -> authorization redirects, the prompt=login flag + // is removed from the authorization request payload before redirecting the user. + var prompt = string.Join(" ", request.GetPrompts().Remove(OpenIddictConstants.Prompts.Login)); + + var parameters = Request.HasFormContentType + ? Request.Form.Where(parameter => parameter.Key != OpenIddictConstants.Parameters.Prompt).ToList() + : Request.Query.Where(parameter => parameter.Key != OpenIddictConstants.Parameters.Prompt).ToList(); + + parameters.Add(KeyValuePair.Create(OpenIddictConstants.Parameters.Prompt, new StringValues(prompt))); + + return Challenge( + authenticationSchemes: CookieAuthenticationDefaults.AuthenticationScheme, + properties: new AuthenticationProperties + { + RedirectUri = Request.PathBase + Request.Path + QueryString.Create(parameters) + }); + } + + var userId = lexAuthUser.Id.ToString(); + var requestClientId = request.ClientId; + ArgumentException.ThrowIfNullOrEmpty(requestClientId); + var application = await applicationManager.FindByClientIdAsync(requestClientId) ?? + throw new InvalidOperationException( + "Details concerning the calling client application cannot be found."); + var applicationId = await applicationManager.GetIdAsync(application) ?? + throw new InvalidOperationException("The calling client application could not be found."); + + // Retrieve the permanent authorizations associated with the user and the calling client application. + var authorizations = await authorizationManager.FindAsync( + subject: userId, + client: applicationId, + status: OpenIddictConstants.Statuses.Valid, + type: OpenIddictConstants.AuthorizationTypes.Permanent, + scopes: request.GetScopes()).ToListAsync(); + + switch (await applicationManager.GetConsentTypeAsync(application)) + { + // If the consent is implicit or if an authorization was found, + // return an authorization response without displaying the consent form. + case OpenIddictConstants.ConsentTypes.Implicit: + case OpenIddictConstants.ConsentTypes.External when authorizations.Count is not 0: + case OpenIddictConstants.ConsentTypes.Explicit when authorizations.Count is not 0 && !request.HasPrompt(OpenIddictConstants.Prompts.Consent): + + return await FinishSignIn(lexAuthUser, request, applicationId, authorizations); + + // If the consent is external (e.g when authorizations are granted by a sysadmin), + // immediately return an error if no authorization can be found in the database. + case OpenIddictConstants.ConsentTypes.External when authorizations.Count is 0: + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.ConsentRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = + "The logged in user is not allowed to access this client application." + })); + + // At this point, no authorization was found in the database and an error must be returned + // if the client application specified prompt=none in the authorization request. + case OpenIddictConstants.ConsentTypes.Explicit when request.HasPrompt(OpenIddictConstants.Prompts.None): + case OpenIddictConstants.ConsentTypes.Systematic when request.HasPrompt(OpenIddictConstants.Prompts.None): + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.ConsentRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = + "Interactive user consent is required." + })); + + // In every other case, send user to consent page + default: + var parameters = Request.HasFormContentType + ? Request.Form.ToList() + : Request.Query.ToList(); + var data = JsonSerializer.Serialize(parameters.ToDictionary(pair => pair.Key, pair => pair.Value.ToString())); + var queryString = new QueryString() + .Add("appName", await applicationManager.GetDisplayNameAsync(application) ?? "Unknown app") + .Add("scope", request.Scope ?? "") + .Add("postback", data); + return Redirect($"/authorize{queryString.Value}"); + } + } + + private static bool IsExpired(OpenIddictRequest request, AuthenticateResult result) + { + // If a max_age parameter was provided, ensure that the cookie is not too old. + return (request.MaxAge != null && result.Properties?.IssuedUtc != null && + DateTimeOffset.UtcNow - result.Properties.IssuedUtc > + TimeSpan.FromSeconds(request.MaxAge.Value)); + } + + private bool IsAcceptRequest() + { + return Request.Method == "POST" && Request.Form.ContainsKey("submit.accept") && User.Identity?.IsAuthenticated == true; + } + + [HttpPost("token")] + [AllowAnonymous] + public async Task Exchange() + { + var request = HttpContext.GetOpenIddictServerRequest() ?? + throw new InvalidOperationException("The OpenID Connect request cannot be retrieved."); + // Retrieve the claims principal stored in the authorization code/refresh token. + var result = await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + var lexAuthUser = result.Succeeded ? LexAuthUser.FromClaimsPrincipal(result.Principal) : null; + if (!result.Succeeded || lexAuthUser is null) + { + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.InvalidGrant, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = + "The token is no longer valid." + })); + } + + return await FinishSignIn(lexAuthUser, request); + } + + private async Task FinishSignIn(LexAuthUser lexAuthUser, OpenIddictRequest request) + { + var requestClientId = request.ClientId; + ArgumentException.ThrowIfNullOrEmpty(requestClientId); + var application = await applicationManager.FindByClientIdAsync(requestClientId) ?? + throw new InvalidOperationException( + "Details concerning the calling client application cannot be found."); + // Retrieve the permanent authorizations associated with the user and the calling client application. + var applicationId = await applicationManager.GetIdAsync(application) ?? throw new InvalidOperationException("The calling client application could not be found."); + var authorizations = await authorizationManager.FindAsync( + subject: lexAuthUser.Id.ToString(), + client: applicationId, + status: OpenIddictConstants.Statuses.Valid, + type: OpenIddictConstants.AuthorizationTypes.Permanent, + scopes: request.GetScopes()).ToListAsync(); + + //allow cors response for redirect hosts + var redirectUrisAsync = await applicationManager.GetRedirectUrisAsync(application); + Response.Headers.AccessControlAllowOrigin = redirectUrisAsync + .Select(uri => new Uri(uri).GetComponents(UriComponents.SchemeAndServer, UriFormat.Unescaped)).ToArray(); + + // Note: this check is here to ensure a malicious user can't abuse this POST-only endpoint and + // force it to return a valid response without the external authorization. + if (authorizations.Count is 0 && + await applicationManager.HasConsentTypeAsync(application, OpenIddictConstants.ConsentTypes.External)) + { + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.ConsentRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = + "The logged in user is not allowed to access this client application." + })); + } + + return await FinishSignIn(lexAuthUser, request, applicationId, authorizations); + } + private async Task FinishSignIn(LexAuthUser lexAuthUser, OpenIddictRequest request, string applicationId, List authorizations) + { + var userId = lexAuthUser.Id.ToString(); + // Create the claims-based identity that will be used by OpenIddict to generate tokens. + var identity = new ClaimsIdentity( + authenticationType: TokenValidationParameters.DefaultAuthenticationType, + nameType: OpenIddictConstants.Claims.Name, + roleType: OpenIddictConstants.Claims.Role); + + // Add the claims that will be persisted in the tokens. + identity.SetClaim(OpenIddictConstants.Claims.Subject, userId) + .SetClaim(OpenIddictConstants.Claims.Email, lexAuthUser.Email) + .SetClaim(OpenIddictConstants.Claims.Name, lexAuthUser.Name) + .SetClaim(OpenIddictConstants.Claims.Role, lexAuthUser.Role.ToString()); + + // Note: in this sample, the granted scopes match the requested scope + // but you may want to allow the user to uncheck specific scopes. + // For that, simply restrict the list of scopes before calling SetScopes. + identity.SetScopes(request.GetScopes()); + identity.SetAudiences(LexboxAudience.LexboxApi.ToString()); + // identity.SetResources(await _scopeManager.ListResourcesAsync(identity.GetScopes()).ToListAsync()); + + // Automatically create a permanent authorization to avoid requiring explicit consent + // for future authorization or token requests containing the same scopes. + var authorization = authorizations.LastOrDefault(); + authorization ??= await authorizationManager.CreateAsync( + identity: identity, + subject : userId, + client : applicationId, + type : OpenIddictConstants.AuthorizationTypes.Permanent, + scopes : identity.GetScopes()); + + identity.SetAuthorizationId(await authorizationManager.GetIdAsync(authorization)); + identity.SetDestinations(GetDestinations); + + // Returning a SignInResult will ask OpenIddict to issue the appropriate access/identity tokens. + return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + private static IEnumerable GetDestinations(Claim claim) + { + // Note: by default, claims are NOT automatically included in the access and identity tokens. + // To allow OpenIddict to serialize them, you must attach them a destination, that specifies + // whether they should be included in access tokens, in identity tokens or in both. + + var claimsIdentity = claim.Subject; + ArgumentNullException.ThrowIfNull(claimsIdentity); + switch (claim.Type) + { + case OpenIddictConstants.Claims.Name: + yield return OpenIddictConstants.Destinations.AccessToken; + + if (claimsIdentity.HasScope(OpenIddictConstants.Scopes.Profile)) + yield return OpenIddictConstants.Destinations.IdentityToken; + + yield break; + + case OpenIddictConstants.Claims.Email: + yield return OpenIddictConstants.Destinations.AccessToken; + + if (claimsIdentity.HasScope(OpenIddictConstants.Scopes.Email)) + yield return OpenIddictConstants.Destinations.IdentityToken; + + yield break; + + case OpenIddictConstants.Claims.Role: + yield return OpenIddictConstants.Destinations.AccessToken; + + if (claimsIdentity.HasScope(OpenIddictConstants.Scopes.Roles)) + yield return OpenIddictConstants.Destinations.IdentityToken; + + yield break; + + // Never include the security stamp in the access and identity tokens, as it's a secret value. + case "AspNet.Identity.SecurityStamp": yield break; + + default: + yield return OpenIddictConstants.Destinations.AccessToken; + yield break; + } + } +} diff --git a/frontend/src/routes/(authenticated)/authorize/+page.svelte b/frontend/src/routes/(authenticated)/authorize/+page.svelte index 645e659de..0e09dcff2 100644 --- a/frontend/src/routes/(authenticated)/authorize/+page.svelte +++ b/frontend/src/routes/(authenticated)/authorize/+page.svelte @@ -12,7 +12,7 @@
-
+
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 051c174e1..3879e3cbf 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -34,7 +34,7 @@ export default defineConfig({ codegen(gqlOptions), precompileIntl('src/lib/i18n/locales'), sveltekit(), - // basicSsl() + exposeServer ? basicSsl() : null, // crypto.subtle is only available on secure connections ], optimizeDeps: { },