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"