From 9fefc47d7ff33beabcefe7b922362139b02842ae Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 14:18:06 +0000 Subject: [PATCH] Add local SQL Server containers, EF migrations, and README Run SQL Server in Docker Compose or the Aspire AppHost so local development does not need Azure SQL or another cloud database. Apply migrations and seed demo users/clients on startup. Co-authored-by: Jeff Jones --- .env.example | 3 + .gitignore | 3 + .../Authorization.API.AppHost.csproj | 1 + Authorization.API.AppHost/Program.cs | 13 +- .../appsettings.Development.json | 3 + .../Authorization.API.Tests.csproj | 1 + .../DatabaseInitializerTests.cs | 47 ++ Authorization.API/Authorization.API.csproj | 4 + Authorization.API/Data/DatabaseInitializer.cs | 161 ++++++ Authorization.API/Data/SeedOptions.cs | 22 + .../20260826141731_InitialCreate.Designer.cs | 486 ++++++++++++++++++ .../20260826141731_InitialCreate.cs | 389 ++++++++++++++ .../ApplicationDbContextModelSnapshot.cs | 483 +++++++++++++++++ Authorization.API/Program.cs | 10 +- .../appsettings.Development.json | 18 +- Authorization.API/appsettings.json | 8 +- README.md | 108 ++++ docker-compose.yml | 42 ++ scripts/dev-up.sh | 14 + 19 files changed, 1812 insertions(+), 4 deletions(-) create mode 100644 .env.example create mode 100644 Authorization.API.Tests/DatabaseInitializerTests.cs create mode 100644 Authorization.API/Data/DatabaseInitializer.cs create mode 100644 Authorization.API/Data/SeedOptions.cs create mode 100644 Authorization.API/Migrations/20260826141731_InitialCreate.Designer.cs create mode 100644 Authorization.API/Migrations/20260826141731_InitialCreate.cs create mode 100644 Authorization.API/Migrations/ApplicationDbContextModelSnapshot.cs create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100755 scripts/dev-up.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..22af469 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# Local-only SQL Server password used by docker compose and the Aspire AppHost. +# This file is optional; docker-compose.yml already has the same default. +MSSQL_SA_PASSWORD=LocalDev_Sql#2026 diff --git a/.gitignore b/.gitignore index a4fe18b..413f6fe 100644 --- a/.gitignore +++ b/.gitignore @@ -398,3 +398,6 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml + +# Local docker compose overrides +.env diff --git a/Authorization.API.AppHost/Authorization.API.AppHost.csproj b/Authorization.API.AppHost/Authorization.API.AppHost.csproj index b384b72..6c16c92 100644 --- a/Authorization.API.AppHost/Authorization.API.AppHost.csproj +++ b/Authorization.API.AppHost/Authorization.API.AppHost.csproj @@ -13,6 +13,7 @@ + diff --git a/Authorization.API.AppHost/Program.cs b/Authorization.API.AppHost/Program.cs index 3318e9c..7fba30b 100644 --- a/Authorization.API.AppHost/Program.cs +++ b/Authorization.API.AppHost/Program.cs @@ -1,5 +1,16 @@ var builder = DistributedApplication.CreateBuilder(args); -builder.AddProject("authorization-api"); +// Fixed local password so a persisted data volume keeps working across restarts. +// Override with the sql-password parameter / user secret if needed. +var sqlPassword = builder.AddParameter("sql-password", "LocalDev_Sql#2026", secret: true); + +var database = builder.AddSqlServer("sql", password: sqlPassword) + .WithDataVolume("authorization-sql-data") + .AddDatabase("DefaultConnection"); + +builder.AddProject("authorization-api") + .WithReference(database) + .WaitFor(database) + .WithExternalHttpEndpoints(); builder.Build().Run(); diff --git a/Authorization.API.AppHost/appsettings.Development.json b/Authorization.API.AppHost/appsettings.Development.json index 0c208ae..3ce93ad 100644 --- a/Authorization.API.AppHost/appsettings.Development.json +++ b/Authorization.API.AppHost/appsettings.Development.json @@ -4,5 +4,8 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "Parameters": { + "sql-password": "LocalDev_Sql#2026" } } diff --git a/Authorization.API.Tests/Authorization.API.Tests.csproj b/Authorization.API.Tests/Authorization.API.Tests.csproj index 820c448..7d39e2c 100644 --- a/Authorization.API.Tests/Authorization.API.Tests.csproj +++ b/Authorization.API.Tests/Authorization.API.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/Authorization.API.Tests/DatabaseInitializerTests.cs b/Authorization.API.Tests/DatabaseInitializerTests.cs new file mode 100644 index 0000000..aee44ef --- /dev/null +++ b/Authorization.API.Tests/DatabaseInitializerTests.cs @@ -0,0 +1,47 @@ +using Authorization.API.Context; +using Authorization.API.Data; +using Authorization.API.Models; +using Microsoft.AspNetCore.Identity; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Authorization.API.Tests; + +public class DatabaseInitializerTests +{ + [Fact] + public async Task SeedAsync_CreatesDemoUsersAndClient() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDbContext(options => options.UseSqlite(connection)); + services.AddIdentity() + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + + await using var provider = services.BuildServiceProvider(); + await using var scope = provider.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.EnsureCreatedAsync(); + + var options = new SeedOptions { Enabled = true }; + await DatabaseInitializer.SeedAsync(scope.ServiceProvider, options, NullLogger.Instance); + + var userManager = scope.ServiceProvider.GetRequiredService>(); + var admin = await userManager.FindByEmailAsync(options.AdminEmail); + var demo = await userManager.FindByEmailAsync(options.DemoUserEmail); + var client = await db.Clients.SingleAsync(c => c.ClientId == options.DemoClientId); + + Assert.NotNull(admin); + Assert.NotNull(demo); + Assert.True(await userManager.IsInRoleAsync(admin!, "Administrator")); + Assert.Equal(options.DemoClientSecret, client.ClientSecret); + Assert.Contains("api", client.AllowedScopes); + Assert.True(await db.ApiScopes.AnyAsync(s => s.Name == "openid")); + } +} diff --git a/Authorization.API/Authorization.API.csproj b/Authorization.API/Authorization.API.csproj index fabd9a6..feb5043 100644 --- a/Authorization.API/Authorization.API.csproj +++ b/Authorization.API/Authorization.API.csproj @@ -12,6 +12,10 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + diff --git a/Authorization.API/Data/DatabaseInitializer.cs b/Authorization.API/Data/DatabaseInitializer.cs new file mode 100644 index 0000000..e97a998 --- /dev/null +++ b/Authorization.API/Data/DatabaseInitializer.cs @@ -0,0 +1,161 @@ +using Authorization.API.Context; +using Authorization.API.Models; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Authorization.API.Data; + +public static class DatabaseInitializer +{ + public static async Task InitializeAsync(IServiceProvider services, CancellationToken cancellationToken = default) + { + await using var scope = services.CreateAsyncScope(); + var provider = scope.ServiceProvider; + var config = provider.GetRequiredService(); + var logger = provider.GetRequiredService().CreateLogger(nameof(DatabaseInitializer)); + + var migrateOnStartup = config.GetValue("Database:MigrateOnStartup", false); + if (!migrateOnStartup) + { + return; + } + + var db = provider.GetRequiredService(); + await WaitAndMigrateAsync(db, logger, cancellationToken); + + var seedOptions = provider.GetRequiredService>().Value; + if (!seedOptions.Enabled) + { + return; + } + + await SeedAsync(provider, seedOptions, logger, cancellationToken); + } + + public static async Task WaitAndMigrateAsync( + ApplicationDbContext db, + ILogger logger, + CancellationToken cancellationToken = default) + { + var delaysSeconds = new[] { 2, 3, 5, 8, 8, 8, 8, 8, 8, 8 }; + + for (var attempt = 0; attempt < delaysSeconds.Length; attempt++) + { + try + { + if (db.Database.IsRelational()) + { + if (db.Database.ProviderName == "Microsoft.EntityFrameworkCore.Sqlite") + { + await db.Database.EnsureCreatedAsync(cancellationToken); + } + else + { + await db.Database.MigrateAsync(cancellationToken); + } + } + else + { + await db.Database.EnsureCreatedAsync(cancellationToken); + } + + logger.LogInformation("Database is ready."); + return; + } + catch (Exception ex) when (attempt < delaysSeconds.Length - 1 && !cancellationToken.IsCancellationRequested) + { + logger.LogWarning( + ex, + "Database is not ready (attempt {Attempt}/{Total}). Retrying in {Delay}s.", + attempt + 1, + delaysSeconds.Length, + delaysSeconds[attempt]); + await Task.Delay(TimeSpan.FromSeconds(delaysSeconds[attempt]), cancellationToken); + } + } + } + + public static async Task SeedAsync( + IServiceProvider services, + SeedOptions options, + ILogger logger, + CancellationToken cancellationToken = default) + { + var roleManager = services.GetRequiredService>(); + var userManager = services.GetRequiredService>(); + var db = services.GetRequiredService(); + + foreach (var roleName in new[] { "Administrator", "User" }) + { + if (!await roleManager.RoleExistsAsync(roleName)) + { + await roleManager.CreateAsync(new ApplicationRole(roleName)); + } + } + + await EnsureUserAsync(userManager, options.AdminEmail, options.AdminPassword, "Administrator"); + await EnsureUserAsync(userManager, options.DemoUserEmail, options.DemoUserPassword, "User"); + + foreach (var (name, description) in new[] + { + ("openid", "OpenID"), + ("profile", "User profile"), + ("email", "Email address"), + ("api", "API access") + }) + { + if (!await db.ApiScopes.AnyAsync(s => s.Name == name, cancellationToken)) + { + db.ApiScopes.Add(new ApiScope { Name = name, Description = description }); + } + } + + if (!await db.Clients.AnyAsync(c => c.ClientId == options.DemoClientId, cancellationToken)) + { + db.Clients.Add(new Client + { + ClientId = options.DemoClientId, + Description = "Local demo confidential client", + ClientSecret = options.DemoClientSecret, + RedirectUri = options.DemoRedirectUri, + RequirePkce = true, + AllowRefreshToken = true, + AllowedScopes = ["openid", "profile", "email", "api"] + }); + } + + await db.SaveChangesAsync(cancellationToken); + logger.LogInformation("Development seed data is in place."); + } + + private static async Task EnsureUserAsync( + UserManager userManager, + string email, + string password, + string role) + { + var user = await userManager.FindByEmailAsync(email); + if (user == null) + { + user = new ApplicationUser + { + UserName = email, + Email = email, + EmailConfirmed = true + }; + + var result = await userManager.CreateAsync(user, password); + if (!result.Succeeded) + { + throw new InvalidOperationException( + $"Failed to seed user {email}: {string.Join(", ", result.Errors.Select(e => e.Description))}"); + } + } + + if (!await userManager.IsInRoleAsync(user, role)) + { + await userManager.AddToRoleAsync(user, role); + } + } +} diff --git a/Authorization.API/Data/SeedOptions.cs b/Authorization.API/Data/SeedOptions.cs new file mode 100644 index 0000000..e4ba98d --- /dev/null +++ b/Authorization.API/Data/SeedOptions.cs @@ -0,0 +1,22 @@ +namespace Authorization.API.Data; + +public class SeedOptions +{ + public const string SectionName = "Seed"; + + public bool Enabled { get; set; } + + public string AdminEmail { get; set; } = "admin@localhost"; + + public string AdminPassword { get; set; } = "Admin123!"; + + public string DemoUserEmail { get; set; } = "demo@example.com"; + + public string DemoUserPassword { get; set; } = "Password1!"; + + public string DemoClientId { get; set; } = "demo-client"; + + public string DemoClientSecret { get; set; } = "demo-secret"; + + public string DemoRedirectUri { get; set; } = "http://localhost:3000/callback"; +} diff --git a/Authorization.API/Migrations/20260826141731_InitialCreate.Designer.cs b/Authorization.API/Migrations/20260826141731_InitialCreate.Designer.cs new file mode 100644 index 0000000..886729f --- /dev/null +++ b/Authorization.API/Migrations/20260826141731_InitialCreate.Designer.cs @@ -0,0 +1,486 @@ +// +using System; +using Authorization.API.Context; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Authorization.API.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260826141731_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Authorization.API.Models.ApiResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("ApiResources"); + }); + + modelBuilder.Entity("Authorization.API.Models.ApiScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ApiResourceId") + .HasColumnType("int"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("ApiResourceId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiScopes"); + }); + + modelBuilder.Entity("Authorization.API.Models.ApplicationRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("Authorization.API.Models.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FullName") + .HasColumnType("nvarchar(max)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Authorization.API.Models.AuthorizationCode", b => + { + b.Property("Code") + .HasColumnType("nvarchar(450)"); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("CodeChallenge") + .HasColumnType("nvarchar(max)"); + + b.Property("CodeChallengeMethod") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsUsed") + .HasColumnType("bit"); + + b.Property("RedirectUri") + .HasColumnType("nvarchar(max)"); + + b.Property("Scopes") + .HasColumnType("nvarchar(max)"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("Code"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("ClientId", "UserId"); + + b.ToTable("AuthorizationCodes"); + }); + + modelBuilder.Entity("Authorization.API.Models.Client", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AllowRefreshToken") + .HasColumnType("bit"); + + b.PrimitiveCollection("AllowedScopes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("ClientSecret") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("PostLogoutRedirectUri") + .HasColumnType("nvarchar(max)"); + + b.Property("RedirectUri") + .HasColumnType("nvarchar(max)"); + + b.Property("RequirePkce") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("Clients"); + }); + + modelBuilder.Entity("Authorization.API.Models.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClientId") + .HasColumnType("nvarchar(450)"); + + b.Property("Created") + .HasColumnType("datetime2"); + + b.Property("Expiry") + .HasColumnType("datetime2"); + + b.Property("IsRevoked") + .HasColumnType("bit"); + + b.Property("Token") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Authorization.API.Models.ApiScope", b => + { + b.HasOne("Authorization.API.Models.ApiResource", null) + .WithMany("Scopes") + .HasForeignKey("ApiResourceId"); + }); + + modelBuilder.Entity("Authorization.API.Models.RefreshToken", b => + { + b.HasOne("Authorization.API.Models.Client", "Client") + .WithMany() + .HasForeignKey("ClientId") + .HasPrincipalKey("ClientId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Authorization.API.Models.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Client"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Authorization.API.Models.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Authorization.API.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Authorization.API.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Authorization.API.Models.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Authorization.API.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Authorization.API.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Authorization.API.Models.ApiResource", b => + { + b.Navigation("Scopes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Authorization.API/Migrations/20260826141731_InitialCreate.cs b/Authorization.API/Migrations/20260826141731_InitialCreate.cs new file mode 100644 index 0000000..62309a9 --- /dev/null +++ b/Authorization.API/Migrations/20260826141731_InitialCreate.cs @@ -0,0 +1,389 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Authorization.API.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ApiResources", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ApiResources", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AuthorizationCodes", + columns: table => new + { + Code = table.Column(type: "nvarchar(450)", nullable: false), + ClientId = table.Column(type: "nvarchar(450)", nullable: false), + UserId = table.Column(type: "nvarchar(450)", nullable: true), + Subject = table.Column(type: "nvarchar(max)", nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false), + ExpiresAt = table.Column(type: "datetime2", nullable: false), + IsUsed = table.Column(type: "bit", nullable: false), + RedirectUri = table.Column(type: "nvarchar(max)", nullable: true), + CodeChallenge = table.Column(type: "nvarchar(max)", nullable: true), + CodeChallengeMethod = table.Column(type: "nvarchar(max)", nullable: true), + Scopes = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AuthorizationCodes", x => x.Code); + }); + + migrationBuilder.CreateTable( + name: "Clients", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + ClientId = table.Column(type: "nvarchar(450)", nullable: false), + Description = table.Column(type: "nvarchar(max)", nullable: true), + ClientSecret = table.Column(type: "nvarchar(max)", nullable: false), + RedirectUri = table.Column(type: "nvarchar(max)", nullable: true), + PostLogoutRedirectUri = table.Column(type: "nvarchar(max)", nullable: true), + RequirePkce = table.Column(type: "bit", nullable: false), + AllowRefreshToken = table.Column(type: "bit", nullable: false), + AllowedScopes = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Clients", x => x.Id); + table.UniqueConstraint("AK_Clients_ClientId", x => x.ClientId); + }); + + migrationBuilder.CreateTable( + name: "Roles", + columns: table => new + { + Id = table.Column(type: "nvarchar(450)", nullable: false), + Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Roles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "nvarchar(450)", nullable: false), + FullName = table.Column(type: "nvarchar(max)", nullable: true), + UserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "bit", nullable: false), + PasswordHash = table.Column(type: "nvarchar(max)", nullable: true), + SecurityStamp = table.Column(type: "nvarchar(max)", nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumber = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumberConfirmed = table.Column(type: "bit", nullable: false), + TwoFactorEnabled = table.Column(type: "bit", nullable: false), + LockoutEnd = table.Column(type: "datetimeoffset", nullable: true), + LockoutEnabled = table.Column(type: "bit", nullable: false), + AccessFailedCount = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ApiScopes", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(450)", nullable: false), + Description = table.Column(type: "nvarchar(max)", nullable: true), + ApiResourceId = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ApiScopes", x => x.Id); + table.ForeignKey( + name: "FK_ApiScopes_ApiResources_ApiResourceId", + column: x => x.ApiResourceId, + principalTable: "ApiResources", + principalColumn: "Id"); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + RoleId = table.Column(type: "nvarchar(450)", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetRoleClaims_Roles_RoleId", + column: x => x.RoleId, + principalTable: "Roles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserId = table.Column(type: "nvarchar(450)", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + columns: table => new + { + LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), + ProviderKey = table.Column(type: "nvarchar(450)", nullable: false), + ProviderDisplayName = table.Column(type: "nvarchar(max)", nullable: true), + UserId = table.Column(type: "nvarchar(450)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AspNetUserLogins_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + columns: table => new + { + UserId = table.Column(type: "nvarchar(450)", nullable: false), + RoleId = table.Column(type: "nvarchar(450)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AspNetUserRoles_Roles_RoleId", + column: x => x.RoleId, + principalTable: "Roles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AspNetUserRoles_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + columns: table => new + { + UserId = table.Column(type: "nvarchar(450)", nullable: false), + LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), + Name = table.Column(type: "nvarchar(450)", nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AspNetUserTokens_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "RefreshTokens", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Token = table.Column(type: "nvarchar(450)", nullable: false), + UserId = table.Column(type: "nvarchar(450)", nullable: true), + ClientId = table.Column(type: "nvarchar(450)", nullable: true), + Expiry = table.Column(type: "datetime2", nullable: false), + IsRevoked = table.Column(type: "bit", nullable: false), + Created = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RefreshTokens", x => x.Id); + table.ForeignKey( + name: "FK_RefreshTokens_Clients_ClientId", + column: x => x.ClientId, + principalTable: "Clients", + principalColumn: "ClientId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_RefreshTokens_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ApiScopes_ApiResourceId", + table: "ApiScopes", + column: "ApiResourceId"); + + migrationBuilder.CreateIndex( + name: "IX_ApiScopes_Name", + table: "ApiScopes", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + table: "AspNetRoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + table: "AspNetUserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "IX_AuthorizationCodes_ClientId_UserId", + table: "AuthorizationCodes", + columns: new[] { "ClientId", "UserId" }); + + migrationBuilder.CreateIndex( + name: "IX_AuthorizationCodes_Code", + table: "AuthorizationCodes", + column: "Code", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Clients_ClientId", + table: "Clients", + column: "ClientId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_ClientId", + table: "RefreshTokens", + column: "ClientId"); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_Token", + table: "RefreshTokens", + column: "Token", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_UserId", + table: "RefreshTokens", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "Roles", + column: "NormalizedName", + unique: true, + filter: "[NormalizedName] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "Users", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "Users", + column: "NormalizedUserName", + unique: true, + filter: "[NormalizedUserName] IS NOT NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ApiScopes"); + + migrationBuilder.DropTable( + name: "AspNetRoleClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins"); + + migrationBuilder.DropTable( + name: "AspNetUserRoles"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens"); + + migrationBuilder.DropTable( + name: "AuthorizationCodes"); + + migrationBuilder.DropTable( + name: "RefreshTokens"); + + migrationBuilder.DropTable( + name: "ApiResources"); + + migrationBuilder.DropTable( + name: "Roles"); + + migrationBuilder.DropTable( + name: "Clients"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/Authorization.API/Migrations/ApplicationDbContextModelSnapshot.cs b/Authorization.API/Migrations/ApplicationDbContextModelSnapshot.cs new file mode 100644 index 0000000..da4baa7 --- /dev/null +++ b/Authorization.API/Migrations/ApplicationDbContextModelSnapshot.cs @@ -0,0 +1,483 @@ +// +using System; +using Authorization.API.Context; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Authorization.API.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + partial class ApplicationDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Authorization.API.Models.ApiResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("ApiResources"); + }); + + modelBuilder.Entity("Authorization.API.Models.ApiScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ApiResourceId") + .HasColumnType("int"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("ApiResourceId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiScopes"); + }); + + modelBuilder.Entity("Authorization.API.Models.ApplicationRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("Authorization.API.Models.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FullName") + .HasColumnType("nvarchar(max)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Authorization.API.Models.AuthorizationCode", b => + { + b.Property("Code") + .HasColumnType("nvarchar(450)"); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("CodeChallenge") + .HasColumnType("nvarchar(max)"); + + b.Property("CodeChallengeMethod") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsUsed") + .HasColumnType("bit"); + + b.Property("RedirectUri") + .HasColumnType("nvarchar(max)"); + + b.Property("Scopes") + .HasColumnType("nvarchar(max)"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("Code"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("ClientId", "UserId"); + + b.ToTable("AuthorizationCodes"); + }); + + modelBuilder.Entity("Authorization.API.Models.Client", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AllowRefreshToken") + .HasColumnType("bit"); + + b.PrimitiveCollection("AllowedScopes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("ClientSecret") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("PostLogoutRedirectUri") + .HasColumnType("nvarchar(max)"); + + b.Property("RedirectUri") + .HasColumnType("nvarchar(max)"); + + b.Property("RequirePkce") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("Clients"); + }); + + modelBuilder.Entity("Authorization.API.Models.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClientId") + .HasColumnType("nvarchar(450)"); + + b.Property("Created") + .HasColumnType("datetime2"); + + b.Property("Expiry") + .HasColumnType("datetime2"); + + b.Property("IsRevoked") + .HasColumnType("bit"); + + b.Property("Token") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Authorization.API.Models.ApiScope", b => + { + b.HasOne("Authorization.API.Models.ApiResource", null) + .WithMany("Scopes") + .HasForeignKey("ApiResourceId"); + }); + + modelBuilder.Entity("Authorization.API.Models.RefreshToken", b => + { + b.HasOne("Authorization.API.Models.Client", "Client") + .WithMany() + .HasForeignKey("ClientId") + .HasPrincipalKey("ClientId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Authorization.API.Models.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Client"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Authorization.API.Models.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Authorization.API.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Authorization.API.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Authorization.API.Models.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Authorization.API.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Authorization.API.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Authorization.API.Models.ApiResource", b => + { + b.Navigation("Scopes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Authorization.API/Program.cs b/Authorization.API/Program.cs index 81775ac..1c7b02f 100644 --- a/Authorization.API/Program.cs +++ b/Authorization.API/Program.cs @@ -1,4 +1,5 @@ using Authorization.API.Context; +using Authorization.API.Data; using Authorization.API.HostedService; using Authorization.API.Models; using Authorization.API.Services; @@ -28,9 +29,13 @@ builder.Services.AddControllersWithViews(); builder.Services.AddOpenApi(); +builder.Services.Configure(builder.Configuration.GetSection(SeedOptions.SectionName)); + +var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") + ?? throw new InvalidOperationException("Connection string 'DefaultConnection' is missing."); builder.Services.AddDbContext(options => - options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); + options.UseSqlServer(connectionString)); builder.Services.AddIdentity() .AddEntityFrameworkStores() @@ -38,6 +43,7 @@ builder.Services.Configure(options => { + options.User.RequireUniqueEmail = true; options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15); options.Lockout.MaxFailedAccessAttempts = 5; options.Lockout.AllowedForNewUsers = true; @@ -82,6 +88,8 @@ var app = builder.Build(); +await DatabaseInitializer.InitializeAsync(app.Services); + app.MapDefaultEndpoints(); if (app.Environment.IsDevelopment()) diff --git a/Authorization.API/appsettings.Development.json b/Authorization.API/appsettings.Development.json index bd5d487..429136e 100644 --- a/Authorization.API/appsettings.Development.json +++ b/Authorization.API/appsettings.Development.json @@ -11,5 +11,21 @@ "Audience": "https://localhost:7074", "AccessTokenExpiryMinutes": 30 }, - "EncryptionKey": "TG9jYWxEZXYtQUVTMjU2LUVuY3J5cHRpb24tS2V5cyE=" + "EncryptionKey": "TG9jYWxEZXYtQUVTMjU2LUVuY3J5cHRpb24tS2V5cyE=", + "ConnectionStrings": { + "DefaultConnection": "Server=localhost,1433;Database=AuthorizationApi;User Id=sa;Password=LocalDev_Sql#2026;TrustServerCertificate=True;MultipleActiveResultSets=True" + }, + "Database": { + "MigrateOnStartup": true + }, + "Seed": { + "Enabled": true, + "AdminEmail": "admin@localhost", + "AdminPassword": "Admin123!", + "DemoUserEmail": "demo@example.com", + "DemoUserPassword": "Password1!", + "DemoClientId": "demo-client", + "DemoClientSecret": "demo-secret", + "DemoRedirectUri": "http://localhost:3000/callback" + } } diff --git a/Authorization.API/appsettings.json b/Authorization.API/appsettings.json index 7bbbf21..01c1f1e 100644 --- a/Authorization.API/appsettings.json +++ b/Authorization.API/appsettings.json @@ -12,6 +12,12 @@ "AccessTokenExpiryMinutes": 30 }, "ConnectionStrings": { - "DefaultConnection": "Server=localhost;Database=AuthorizationApi;Trusted_Connection=True;TrustServerCertificate=True;" + "DefaultConnection": "Server=localhost,1433;Database=AuthorizationApi;User Id=sa;Password=CHANGE_ME;TrustServerCertificate=True;MultipleActiveResultSets=True" + }, + "Database": { + "MigrateOnStartup": false + }, + "Seed": { + "Enabled": false } } diff --git a/README.md b/README.md new file mode 100644 index 0000000..a8cb21e --- /dev/null +++ b/README.md @@ -0,0 +1,108 @@ +# Authorization.API + +A self-hosted OAuth 2.0 authorization server for your own apps. It is MIT-licensed, runs on ASP.NET Core 9, and does not require Duende, Auth0, or a cloud identity product. + +Supported grants: + +- Authorization code + PKCE +- Refresh token +- Client credentials +- Resource-owner JSON login (`POST /account/login/token`) for first-party apps + +## Prerequisites + +- [.NET 9 SDK](https://dotnet.microsoft.com/download) +- [Docker](https://docs.docker.com/get-docker/) for the local SQL Server container (no Azure SQL / RDS required) + +## Quick start (Docker Compose) + +This is the simplest local path: SQL Server and the API both run in containers. + +```bash +docker compose up --build +``` + +Then open: + +- API: http://localhost:8080 +- Scalar docs: http://localhost:8080/scalar +- Login: http://localhost:8080/account/login + +The API applies EF Core migrations and seeds demo data on startup. + +### Demo credentials (local only) + +| Kind | Value | +| --- | --- | +| Admin user | `admin@localhost` / `Admin123!` | +| Demo user | `demo@example.com` / `Password1!` | +| Confidential client | `demo-client` / `demo-secret` | +| Redirect URI | `http://localhost:3000/callback` | + +Change these before any shared or production use. + +## Alternative: Aspire AppHost + +If you already use .NET Aspire, the AppHost starts a SQL Server container, waits until it is healthy, and runs the API against it: + +```bash +dotnet run --project Authorization.API.AppHost +``` + +SQL Server data is stored in a Docker volume (`authorization-sql-data`) so it survives restarts. The local SA password defaults to `LocalDev_Sql#2026`. + +## Alternative: SQL container + API on the host + +```bash +docker compose up -d sqlserver +dotnet run --project Authorization.API --launch-profile http +``` + +`appsettings.Development.json` already points at `localhost,1433` with the same local SA password. + +## Configuration + +Set secrets with environment variables or user secrets, not production config files: + +| Setting | Purpose | +| --- | --- | +| `ConnectionStrings__DefaultConnection` | SQL Server connection string | +| `Jwt__SecretKey` | HMAC signing key for access tokens | +| `Jwt__Issuer` / `Jwt__Audience` | Token issuer and audience | +| `EncryptionKey` | Base64 AES-256 key (32 bytes decoded) | +| `Database__MigrateOnStartup` | Apply EF migrations on boot (on in Development) | +| `Seed__Enabled` | Create demo users/clients (on in Development) | + +Production should set `Database__MigrateOnStartup` and `Seed__Enabled` to `false` unless you intentionally want boot-time migration. + +## Example: authorization code + PKCE + +```text +GET /authorize?response_type=code + &client_id=demo-client + &redirect_uri=http://localhost:3000/callback + &scope=openid profile api + &state=abc + &code_challenge=... + &code_challenge_method=S256 +``` + +After sign-in, the browser is redirected back with `code`. Exchange it: + +```bash +curl -X POST http://localhost:8080/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=authorization_code" \ + -d "client_id=demo-client" \ + -d "client_secret=demo-secret" \ + -d "code=..." \ + -d "redirect_uri=http://localhost:3000/callback" \ + -d "code_verifier=..." +``` + +## Project layout + +- `Authorization.API` — HTTP APIs, login UI, token issuance +- `Authorization.API.AppHost` — Aspire orchestrator (SQL container + API) +- `Authorization.API.ServiceDefaults` — health checks and OpenTelemetry +- `docker-compose.yml` — local SQL Server (and optional API) without a cloud database diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..77a64c5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +services: + sqlserver: + image: mcr.microsoft.com/mssql/server:2022-latest + container_name: authorization-sql + environment: + ACCEPT_EULA: "Y" + MSSQL_SA_PASSWORD: "${MSSQL_SA_PASSWORD:-LocalDev_Sql#2026}" + MSSQL_PID: Developer + ports: + - "1433:1433" + volumes: + - authorization-sql-data:/var/opt/mssql + healthcheck: + test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/127.0.0.1/1433'"] + interval: 5s + timeout: 3s + retries: 30 + start_period: 25s + + api: + build: + context: . + dockerfile: Authorization.API/Dockerfile + container_name: authorization-api + environment: + ASPNETCORE_ENVIRONMENT: Development + ASPNETCORE_URLS: http://+:8080 + ConnectionStrings__DefaultConnection: "Server=sqlserver,1433;Database=AuthorizationApi;User Id=sa;Password=${MSSQL_SA_PASSWORD:-LocalDev_Sql#2026};TrustServerCertificate=True;MultipleActiveResultSets=True" + Jwt__SecretKey: DEV_ONLY_JWT_SIGNING_KEY_MUST_BE_LONG_ENOUGH_32 + Jwt__Issuer: http://localhost:8080 + Jwt__Audience: http://localhost:8080 + EncryptionKey: TG9jYWxEZXYtQUVTMjU2LUVuY3J5cHRpb24tS2V5cyE= + Database__MigrateOnStartup: "true" + Seed__Enabled: "true" + ports: + - "8080:8080" + depends_on: + sqlserver: + condition: service_healthy + +volumes: + authorization-sql-data: diff --git a/scripts/dev-up.sh b/scripts/dev-up.sh new file mode 100755 index 0000000..93e5cc6 --- /dev/null +++ b/scripts/dev-up.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$root" + +if ! command -v docker >/dev/null 2>&1; then + echo "Docker is required to run local SQL Server. Install Docker Desktop or the Docker Engine, then retry." >&2 + exit 1 +fi + +docker compose up -d sqlserver +echo "SQL Server is starting on localhost:1433 (sa / LocalDev_Sql#2026)." +echo "Run the API with: dotnet run --project Authorization.API --launch-profile http"