Compare commits

...

7 Commits

2
.gitignore vendored

@ -801,3 +801,5 @@ pyrightconfig.json
# End of https://www.toptal.com/developers/gitignore/api/rider,intellij,intellij+all,dotnetcore,csharp,python
**/appsettings*.json
.DS_Store

@ -9,6 +9,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CatalogService", "src\Catal
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared", "src\Shared\Shared.csproj", "{BF49B348-4188-4AC7-9ED4-5837F4B3BCD2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentitySvc", "src\IdentitySvc\IdentitySvc.csproj", "{74C8ACD5-5DC4-4466-8846-B552FF131304}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -26,9 +28,14 @@ Global
{BF49B348-4188-4AC7-9ED4-5837F4B3BCD2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BF49B348-4188-4AC7-9ED4-5837F4B3BCD2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BF49B348-4188-4AC7-9ED4-5837F4B3BCD2}.Release|Any CPU.Build.0 = Release|Any CPU
{74C8ACD5-5DC4-4466-8846-B552FF131304}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{74C8ACD5-5DC4-4466-8846-B552FF131304}.Debug|Any CPU.Build.0 = Debug|Any CPU
{74C8ACD5-5DC4-4466-8846-B552FF131304}.Release|Any CPU.ActiveCfg = Release|Any CPU
{74C8ACD5-5DC4-4466-8846-B552FF131304}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{54BE8DE8-08BD-429F-BCCA-3363A879D922} = {2A7200CA-F40B-4715-8726-4ED30C785FA4}
{BF49B348-4188-4AC7-9ED4-5837F4B3BCD2} = {2A7200CA-F40B-4715-8726-4ED30C785FA4}
{74C8ACD5-5DC4-4466-8846-B552FF131304} = {2A7200CA-F40B-4715-8726-4ED30C785FA4}
EndGlobalSection
EndGlobal

@ -2,7 +2,7 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>disable</Nullable>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
@ -13,6 +13,7 @@
<ItemGroup>
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.16" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.15">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

@ -0,0 +1,77 @@
using AutoMapper;
using CatalogService.Data;
using CatalogService.DTOs;
using CatalogService.Entities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Shared.DTOs;
namespace CatalogService.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ExercicesController : ControllerBase
{
private readonly CatalogDbContext _context;
private readonly IMapper _mapper;
public ExercicesController(CatalogDbContext context, IMapper mapper)
{
_context = context;
_mapper = mapper;
}
[Authorize]
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateExerciceTemplateDto dto)
{
if (User.Identity.Name != "admin") return Forbid();
var exercice = _mapper.Map<Exercice>(dto);
_context.Exercices.Add(exercice);
await _context.SaveChangesAsync();
return CreatedAtAction(nameof(GetById), new { id = exercice.Id }, _mapper.Map<ExerciceTemplateDto>(exercice));
}
[Authorize]
[HttpPut("{id}")]
public async Task<IActionResult> Update(string id, [FromBody] UpdateExerciceTemplateDto dto)
{
if (User.Identity.Name != "admin") return Forbid();
var exercice = await _context.Exercices.FindAsync(id);
if (exercice == null) return NotFound();
_mapper.Map(dto, exercice);
exercice.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
return NoContent();
}
[Authorize]
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(string id)
{
if (User.Identity.Name != "admin") return Forbid();
var exercice = await _context.Exercices.FindAsync(id);
if (exercice == null) return NotFound();
_context.Exercices.Remove(exercice);
await _context.SaveChangesAsync();
return NoContent();
}
[Authorize]
[HttpGet("{id}")]
public async Task<ActionResult<ExerciceTemplateDto>> GetById(string id)
{
if (User.Identity.Name != "admin") return Forbid();
var exercice = await _context.Exercices.FindAsync(id);
if (exercice == null) return NotFound();
return _mapper.Map<ExerciceTemplateDto>(exercice);
}
}

@ -6,10 +6,9 @@ namespace CatalogService.DTOs;
public class CreateExerciceTemplateDto
{
[Required]
public string Name { get; set; }
public required string Name { get; set; }
[Required]
public string Description { get; set; }
public string? Description { get; set; } = default;
public ETarget? Target { get; set; } = ETarget.None;

@ -1,6 +1,6 @@
using Shared.Enum;
namespace Shared.DTOs;
namespace CatalogService.DTOs;
public class ExerciceTemplateDto
{

@ -0,0 +1,20 @@
using System.ComponentModel.DataAnnotations;
using Shared.Enum;
namespace CatalogService.DTOs;
public class UpdateExerciceTemplateDto
{
[Required]
public required string Id { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public ETarget? Target { get; set; }
public string? ImageUrl { get; set; }
public string? VideoUrl { get; set; }
}

@ -1,4 +1,5 @@
using CatalogService.Data;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
@ -10,10 +11,18 @@ builder.Services.AddDbContext<CatalogDbContext>(opt =>
opt.UseNpgsql(builder.Configuration.GetConnectionString("CatalogDb"));
});
builder.Services.AddAutoMapper(AppDomain.CurrentDomain.GetAssemblies());
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = builder.Configuration["IdentityServiceUrl"];
options.RequireHttpsMetadata = false;
options.TokenValidationParameters.ValidateAudience = false;
options.TokenValidationParameters.NameClaimType = "username";
});
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

@ -0,0 +1,16 @@
using AutoMapper;
using CatalogService.DTOs;
using CatalogService.Entities;
namespace CatalogService.RequestHelpers;
public class MappingProfiles : Profile
{
public MappingProfiles()
{
CreateMap<Exercice, ExerciceTemplateDto>();
CreateMap<ExerciceTemplateDto, Exercice>();
CreateMap<CreateExerciceTemplateDto, Exercice>();
CreateMap<UpdateExerciceTemplateDto, Exercice>();
}
}

@ -0,0 +1,62 @@
using Duende.IdentityServer.Models;
namespace IdentitySvc;
public static class Config
{
public static IEnumerable<IdentityResource> IdentityResources =>
new IdentityResource[]
{
new IdentityResources.OpenId(),
new IdentityResources.Profile(),
};
public static IEnumerable<ApiScope> ApiScopes =>
new ApiScope[]
{
new ApiScope("optifitApp", "Optifit App API Scope"),
//new ApiScope("scope2"),
};
public static IEnumerable<Client> Clients =>
new Client[]
{
// m2m client credentials flow client
/*new Client
{
ClientId = "m2m.client",
ClientName = "Client Credentials Client",
AllowedGrantTypes = GrantTypes.ClientCredentials,
ClientSecrets = { new Secret("511536EF-F270-4058-80CA-1C89C192F69A".Sha256()) },
AllowedScopes = { "scope1" }
},*/
// interactive client using code flow + pkce
/*new Client
{
ClientId = "interactive",
ClientSecrets = { new Secret("49C1A7E1-0C79-4A89-A3D6-A37998FB86B0".Sha256()) },
AllowedGrantTypes = GrantTypes.Code,
RedirectUris = { "https://localhost:44300/signin-oidc" },
FrontChannelLogoutUri = "https://localhost:44300/signout-oidc",
PostLogoutRedirectUris = { "https://localhost:44300/signout-callback-oidc" },
AllowOfflineAccess = true,
AllowedScopes = { "openid", "profile", "scope2" }
},*/
new Client
{
ClientId = "postman",
ClientName = "Postman",
AllowedScopes = {"openid", "profile", "optifitApp"},
RedirectUris = {"https://www.getpostman.com/oauth2/callback"},
ClientSecrets = new[] {new Secret("NotASecret".Sha256())},
AllowedGrantTypes = {GrantType.ResourceOwnerPassword}
}
};
}

@ -0,0 +1,45 @@
using IdentitySvc.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using System.Threading.Tasks;
namespace IdentitySvc.Controllers;
[ApiController]
[Route("api/[controller]")]
public class RegisterApiController : ControllerBase
{
private readonly UserManager<ApplicationUser> _userManager;
public RegisterApiController(UserManager<ApplicationUser> userManager)
{
_userManager = userManager;
}
[HttpPost]
public async Task<IActionResult> Register([FromBody] RegisterApiDto dto)
{
if (dto.Username != "Harry")
return BadRequest("Invalid registration data.");
var user = new ApplicationUser
{
UserName = dto.Username,
Email = dto.Email,
EmailConfirmed = true
};
var result = await _userManager.CreateAsync(user, dto.Password);
if (!result.Succeeded)
return BadRequest(result.Errors);
return StatusCode(201);
}
}
public class RegisterApiDto
{
public string Username { get; set; }
public string Email { get; set; }
public string Password { get; set; }
}

@ -0,0 +1,21 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using IdentitySvc.Models;
namespace IdentitySvc.Data;
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
// Customize the ASP.NET Identity model and override the defaults if needed.
// For example, you can rename the ASP.NET Identity table names and more.
// Add your customizations after calling base.OnModelCreating(builder);
}
}

@ -0,0 +1,277 @@
// <auto-generated />
using System;
using IdentitySvc.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace IdentitySvc.Data.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20250525164319_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("IdentitySvc.Models.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("IdentitySvc.Models.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("IdentitySvc.Models.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("IdentitySvc.Models.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("IdentitySvc.Models.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

@ -0,0 +1,223 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace IdentitySvc.Data.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AspNetRoles",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetUsers",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
UserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
Email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
EmailConfirmed = table.Column<bool>(type: "boolean", nullable: false),
PasswordHash = table.Column<string>(type: "text", nullable: true),
SecurityStamp = table.Column<string>(type: "text", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true),
PhoneNumber = table.Column<string>(type: "text", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "boolean", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "boolean", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
LockoutEnabled = table.Column<bool>(type: "boolean", nullable: false),
AccessFailedCount = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetRoleClaims",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
RoleId = table.Column<string>(type: "text", nullable: false),
ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserClaims",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<string>(type: "text", nullable: false),
ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserLogins",
columns: table => new
{
LoginProvider = table.Column<string>(type: "text", nullable: false),
ProviderKey = table.Column<string>(type: "text", nullable: false),
ProviderDisplayName = table.Column<string>(type: "text", nullable: true),
UserId = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
table.ForeignKey(
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserRoles",
columns: table => new
{
UserId = table.Column<string>(type: "text", nullable: false),
RoleId = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserTokens",
columns: table => new
{
UserId = table.Column<string>(type: "text", nullable: false),
LoginProvider = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Value = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
table.ForeignKey(
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AspNetRoleClaims_RoleId",
table: "AspNetRoleClaims",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "RoleNameIndex",
table: "AspNetRoles",
column: "NormalizedName",
unique: true);
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: "EmailIndex",
table: "AspNetUsers",
column: "NormalizedEmail");
migrationBuilder.CreateIndex(
name: "UserNameIndex",
table: "AspNetUsers",
column: "NormalizedUserName",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AspNetRoleClaims");
migrationBuilder.DropTable(
name: "AspNetUserClaims");
migrationBuilder.DropTable(
name: "AspNetUserLogins");
migrationBuilder.DropTable(
name: "AspNetUserRoles");
migrationBuilder.DropTable(
name: "AspNetUserTokens");
migrationBuilder.DropTable(
name: "AspNetRoles");
migrationBuilder.DropTable(
name: "AspNetUsers");
}
}
}

@ -0,0 +1,274 @@
// <auto-generated />
using System;
using IdentitySvc.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace IdentitySvc.Data.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
partial class ApplicationDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("IdentitySvc.Models.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("IdentitySvc.Models.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("IdentitySvc.Models.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("IdentitySvc.Models.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("IdentitySvc.Models.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

@ -0,0 +1,70 @@
using Duende.IdentityServer;
using IdentitySvc.Data;
using IdentitySvc.Models;
using IdentitySvc.Services;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Serilog;
namespace IdentitySvc;
internal static class HostingExtensions
{
public static WebApplication ConfigureServices(this WebApplicationBuilder builder)
{
builder.Services.AddRazorPages();
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddIdentity<ApplicationUser, IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
builder.Services
.AddIdentityServer(options =>
{
options.Events.RaiseErrorEvents = true;
options.Events.RaiseInformationEvents = true;
options.Events.RaiseFailureEvents = true;
options.Events.RaiseSuccessEvents = true;
// see https://docs.duendesoftware.com/identityserver/v6/fundamentals/resources/
// options.EmitStaticAudienceClaim = true;
})
.AddInMemoryIdentityResources(Config.IdentityResources)
.AddInMemoryApiScopes(Config.ApiScopes)
.AddInMemoryClients(Config.Clients)
.AddAspNetIdentity<ApplicationUser>()
.AddProfileService<CustomProfileService>();
builder.Services.ConfigureApplicationCookie(options =>
{
options.Cookie.SameSite = SameSiteMode.Lax;
});
builder.Services.AddAuthentication();
return builder.Build();
}
public static WebApplication ConfigurePipeline(this WebApplication app)
{
app.UseSerilogRequestLogging();
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseStaticFiles();
app.UseRouting();
app.UseIdentityServer();
app.UseAuthorization();
app.MapRazorPages()
.RequireAuthorization();
return app;
}
}

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Duende.IdentityServer.AspNetIdentity" Version="7.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="8.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.3" />
</ItemGroup>
</Project>

@ -0,0 +1,12 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Microsoft.AspNetCore.Identity;
namespace IdentitySvc.Models;
// Add profile data for application users by adding properties to the ApplicationUser class
public class ApplicationUser : IdentityUser
{
}

@ -0,0 +1,10 @@
@page
@model IdentitySvc.Pages.Account.AccessDeniedModel
@{
}
<div class="row">
<div class="col">
<h1>Access Denied</h1>
<p>You do not have permission to access that resource.</p>
</div>
</div>

@ -0,0 +1,13 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace IdentitySvc.Pages.Account;
public class AccessDeniedModel : PageModel
{
public void OnGet()
{
}
}

@ -0,0 +1,89 @@
@page
@model IdentitySvc.Pages.Login.Index
<div class="login-page">
<partial name="_ValidationSummary" />
<div class="row">
@if (Model.View.EnableLocalLogin)
{
<div class="col-6 offset-3">
<div class="card">
<div class="card-header">
<h2>Login</h2>
</div>
<div class="card-body">
<form asp-page="/Account/Login/Index">
<input type="hidden" asp-for="Input.ReturnUrl" />
<div class="form-group">
<label asp-for="Input.Username"></label>
<input class="form-control" placeholder="Username" asp-for="Input.Username" autofocus>
</div>
<div class="form-group">
<label asp-for="Input.Password"></label>
<input type="password" class="form-control" placeholder="Password" asp-for="Input.Password" autocomplete="off">
</div>
@if (Model.View.AllowRememberLogin)
{
<div class="form-group">
<div class="form-check">
<input class="form-check-input" asp-for="Input.RememberLogin">
<label class="form-check-label" asp-for="Input.RememberLogin">
Remember My Login
</label>
</div>
</div>
}
<a class="pb-2 d-block" asp-page="../Register/Index" asp-route-returnUrl="@Model.Input.ReturnUrl">
New User? Register here
</a>
<button class="btn btn-primary" name="Input.Button" value="login">Login</button>
<button class="btn btn-secondary" name="Input.Button" value="cancel">Cancel</button>
</form>
</div>
</div>
</div>
}
@if (Model.View.VisibleExternalProviders.Any())
{
<div class="col-sm-6">
<div class="card">
<div class="card-header">
<h2>External Account</h2>
</div>
<div class="card-body">
<ul class="list-inline">
@foreach (var provider in Model.View.VisibleExternalProviders)
{
<li class="list-inline-item">
<a class="btn btn-secondary"
asp-page="/ExternalLogin/Challenge"
asp-route-scheme="@provider.AuthenticationScheme"
asp-route-returnUrl="@Model.Input.ReturnUrl">
@provider.DisplayName
</a>
</li>
}
</ul>
</div>
</div>
</div>
}
@if (!Model.View.EnableLocalLogin && !Model.View.VisibleExternalProviders.Any())
{
<div class="alert alert-warning">
<strong>Invalid login request</strong>
There are no login schemes configured for this request.
</div>
}
</div>
</div>

@ -0,0 +1,216 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.IdentityServer;
using Duende.IdentityServer.Events;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Services;
using Duende.IdentityServer.Stores;
using IdentitySvc.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace IdentitySvc.Pages.Login;
[SecurityHeaders]
[AllowAnonymous]
public class Index : PageModel
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly IIdentityServerInteractionService _interaction;
private readonly IEventService _events;
private readonly IAuthenticationSchemeProvider _schemeProvider;
private readonly IIdentityProviderStore _identityProviderStore;
public ViewModel View { get; set; } = default!;
[BindProperty]
public InputModel Input { get; set; } = default!;
public Index(
IIdentityServerInteractionService interaction,
IAuthenticationSchemeProvider schemeProvider,
IIdentityProviderStore identityProviderStore,
IEventService events,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager)
{
_userManager = userManager;
_signInManager = signInManager;
_interaction = interaction;
_schemeProvider = schemeProvider;
_identityProviderStore = identityProviderStore;
_events = events;
}
public async Task<IActionResult> OnGet(string? returnUrl)
{
await BuildModelAsync(returnUrl);
if (View.IsExternalLoginOnly)
{
// we only have one option for logging in and it's an external provider
return RedirectToPage("/ExternalLogin/Challenge", new { scheme = View.ExternalLoginScheme, returnUrl });
}
return Page();
}
public async Task<IActionResult> OnPost()
{
// check if we are in the context of an authorization request
var context = await _interaction.GetAuthorizationContextAsync(Input.ReturnUrl);
// the user clicked the "cancel" button
if (Input.Button != "login")
{
if (context != null)
{
// This "can't happen", because if the ReturnUrl was null, then the context would be null
ArgumentNullException.ThrowIfNull(Input.ReturnUrl, nameof(Input.ReturnUrl));
// if the user cancels, send a result back into IdentityServer as if they
// denied the consent (even if this client does not require consent).
// this will send back an access denied OIDC error response to the client.
await _interaction.DenyAuthorizationAsync(context, AuthorizationError.AccessDenied);
// we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null
if (context.IsNativeClient())
{
// The client is native, so this change in how to
// return the response is for better UX for the end user.
return this.LoadingPage(Input.ReturnUrl);
}
return Redirect(Input.ReturnUrl ?? "~/");
}
else
{
// since we don't have a valid context, then we just go back to the home page
return Redirect("~/");
}
}
if (ModelState.IsValid)
{
var result = await _signInManager.PasswordSignInAsync(Input.Username!, Input.Password!, Input.RememberLogin, lockoutOnFailure: true);
if (result.Succeeded)
{
var user = await _userManager.FindByNameAsync(Input.Username!);
await _events.RaiseAsync(new UserLoginSuccessEvent(user!.UserName, user.Id, user.UserName, clientId: context?.Client.ClientId));
Telemetry.Metrics.UserLogin(context?.Client.ClientId, IdentityServerConstants.LocalIdentityProvider);
if (context != null)
{
// This "can't happen", because if the ReturnUrl was null, then the context would be null
ArgumentNullException.ThrowIfNull(Input.ReturnUrl, nameof(Input.ReturnUrl));
if (context.IsNativeClient())
{
// The client is native, so this change in how to
// return the response is for better UX for the end user.
return this.LoadingPage(Input.ReturnUrl);
}
// we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null
return Redirect(Input.ReturnUrl ?? "~/");
}
// request for a local page
if (Url.IsLocalUrl(Input.ReturnUrl))
{
return Redirect(Input.ReturnUrl);
}
else if (string.IsNullOrEmpty(Input.ReturnUrl))
{
return Redirect("~/");
}
else
{
// user might have clicked on a malicious link - should be logged
throw new ArgumentException("invalid return URL");
}
}
const string error = "invalid credentials";
await _events.RaiseAsync(new UserLoginFailureEvent(Input.Username, error, clientId:context?.Client.ClientId));
Telemetry.Metrics.UserLoginFailure(context?.Client.ClientId, IdentityServerConstants.LocalIdentityProvider, error);
ModelState.AddModelError(string.Empty, LoginOptions.InvalidCredentialsErrorMessage);
}
// something went wrong, show form with error
await BuildModelAsync(Input.ReturnUrl);
return Page();
}
private async Task BuildModelAsync(string? returnUrl)
{
Input = new InputModel
{
ReturnUrl = returnUrl
};
var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
if (context?.IdP != null && await _schemeProvider.GetSchemeAsync(context.IdP) != null)
{
var local = context.IdP == Duende.IdentityServer.IdentityServerConstants.LocalIdentityProvider;
// this is meant to short circuit the UI and only trigger the one external IdP
View = new ViewModel
{
EnableLocalLogin = local,
};
Input.Username = context.LoginHint;
if (!local)
{
View.ExternalProviders = new[] { new ViewModel.ExternalProvider ( authenticationScheme: context.IdP ) };
}
return;
}
var schemes = await _schemeProvider.GetAllSchemesAsync();
var providers = schemes
.Where(x => x.DisplayName != null)
.Select(x => new ViewModel.ExternalProvider
(
authenticationScheme: x.Name,
displayName: x.DisplayName ?? x.Name
)).ToList();
var dynamicSchemes = (await _identityProviderStore.GetAllSchemeNamesAsync())
.Where(x => x.Enabled)
.Select(x => new ViewModel.ExternalProvider
(
authenticationScheme: x.Scheme,
displayName: x.DisplayName ?? x.Scheme
));
providers.AddRange(dynamicSchemes);
var allowLocal = true;
var client = context?.Client;
if (client != null)
{
allowLocal = client.EnableLocalLogin;
if (client.IdentityProviderRestrictions != null && client.IdentityProviderRestrictions.Count != 0)
{
providers = providers.Where(provider => client.IdentityProviderRestrictions.Contains(provider.AuthenticationScheme)).ToList();
}
}
View = new ViewModel
{
AllowRememberLogin = LoginOptions.AllowRememberLogin,
EnableLocalLogin = allowLocal && LoginOptions.AllowLocalLogin,
ExternalProviders = providers.ToArray()
};
}
}

@ -0,0 +1,17 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.ComponentModel.DataAnnotations;
namespace IdentitySvc.Pages.Login;
public class InputModel
{
[Required]
public string? Username { get; set; }
[Required]
public string? Password { get; set; }
public bool RememberLogin { get; set; }
public string? ReturnUrl { get; set; }
public string? Button { get; set; }
}

@ -0,0 +1,12 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace IdentitySvc.Pages.Login;
public static class LoginOptions
{
public static readonly bool AllowLocalLogin = true;
public static readonly bool AllowRememberLogin = true;
public static readonly TimeSpan RememberMeLoginDuration = TimeSpan.FromDays(30);
public static readonly string InvalidCredentialsErrorMessage = "Invalid username or password";
}

@ -0,0 +1,28 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace IdentitySvc.Pages.Login;
public class ViewModel
{
public bool AllowRememberLogin { get; set; } = true;
public bool EnableLocalLogin { get; set; } = true;
public IEnumerable<ViewModel.ExternalProvider> ExternalProviders { get; set; } = Enumerable.Empty<ExternalProvider>();
public IEnumerable<ViewModel.ExternalProvider> VisibleExternalProviders => ExternalProviders.Where(x => !String.IsNullOrWhiteSpace(x.DisplayName));
public bool IsExternalLoginOnly => EnableLocalLogin == false && ExternalProviders?.Count() == 1;
public string? ExternalLoginScheme => IsExternalLoginOnly ? ExternalProviders?.SingleOrDefault()?.AuthenticationScheme : null;
public class ExternalProvider
{
public ExternalProvider(string authenticationScheme, string? displayName = null)
{
AuthenticationScheme = authenticationScheme;
DisplayName = displayName;
}
public string? DisplayName { get; set; }
public string AuthenticationScheme { get; set; }
}
}

@ -0,0 +1,17 @@
@page
@model IdentitySvc.Pages.Logout.Index
<div class="logout-page">
<div class="lead">
<h1>Logout</h1>
<p>Would you like to logout of IdentityServer?</p>
</div>
<form asp-page="/Account/Logout/Index">
<input type="hidden" name="logoutId" value="@Model.LogoutId" />
<div class="form-group">
<button class="btn btn-primary">Yes</button>
</div>
</form>
</div>

@ -0,0 +1,104 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.IdentityServer.Events;
using Duende.IdentityServer.Extensions;
using Duende.IdentityServer.Services;
using IdentityModel;
using IdentitySvc.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace IdentitySvc.Pages.Logout;
[SecurityHeaders]
[AllowAnonymous]
public class Index : PageModel
{
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly IIdentityServerInteractionService _interaction;
private readonly IEventService _events;
[BindProperty]
public string? LogoutId { get; set; }
public Index(SignInManager<ApplicationUser> signInManager, IIdentityServerInteractionService interaction, IEventService events)
{
_signInManager = signInManager;
_interaction = interaction;
_events = events;
}
public async Task<IActionResult> OnGet(string? logoutId)
{
LogoutId = logoutId;
var showLogoutPrompt = LogoutOptions.ShowLogoutPrompt;
if (User.Identity?.IsAuthenticated != true)
{
// if the user is not authenticated, then just show logged out page
showLogoutPrompt = false;
}
else
{
var context = await _interaction.GetLogoutContextAsync(LogoutId);
if (context?.ShowSignoutPrompt == false)
{
// it's safe to automatically sign-out
showLogoutPrompt = false;
}
}
if (showLogoutPrompt == false)
{
// if the request for logout was properly authenticated from IdentityServer, then
// we don't need to show the prompt and can just log the user out directly.
return await OnPost();
}
return Page();
}
public async Task<IActionResult> OnPost()
{
if (User.Identity?.IsAuthenticated == true)
{
// if there's no current logout context, we need to create one
// this captures necessary info from the current logged in user
// this can still return null if there is no context needed
LogoutId ??= await _interaction.CreateLogoutContextAsync();
// delete local authentication cookie
await _signInManager.SignOutAsync();
// see if we need to trigger federated logout
var idp = User.FindFirst(JwtClaimTypes.IdentityProvider)?.Value;
// raise the logout event
await _events.RaiseAsync(new UserLogoutSuccessEvent(User.GetSubjectId(), User.GetDisplayName()));
Telemetry.Metrics.UserLogout(idp);
// if it's a local login we can ignore this workflow
if (idp != null && idp != Duende.IdentityServer.IdentityServerConstants.LocalIdentityProvider)
{
// we need to see if the provider supports external logout
if (await HttpContext.GetSchemeSupportsSignOutAsync(idp))
{
// build a return URL so the upstream provider will redirect back
// to us after the user has logged out. this allows us to then
// complete our single sign-out processing.
var url = Url.Page("/Account/Logout/Loggedout", new { logoutId = LogoutId });
// this triggers a redirect to the external provider for sign-out
return SignOut(new AuthenticationProperties { RedirectUri = url }, idp);
}
}
}
return RedirectToPage("/Account/Logout/LoggedOut", new { logoutId = LogoutId });
}
}

@ -0,0 +1,30 @@
@page
@model IdentitySvc.Pages.Logout.LoggedOut
<div class="logged-out-page">
<h1>
Logout
<small>You are now logged out</small>
</h1>
@if (Model.View.PostLogoutRedirectUri != null)
{
<div>
Click <a class="PostLogoutRedirectUri" href="@Model.View.PostLogoutRedirectUri">here</a> to return to the
<span>@Model.View.ClientName</span> application.
</div>
}
@if (Model.View.SignOutIframeUrl != null)
{
<iframe width="0" height="0" class="signout" src="@Model.View.SignOutIframeUrl"></iframe>
}
</div>
@section scripts
{
@if (Model.View.AutomaticRedirectAfterSignOut)
{
<script src="~/js/signout-redirect.js"></script>
}
}

@ -0,0 +1,36 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.IdentityServer.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace IdentitySvc.Pages.Logout;
[SecurityHeaders]
[AllowAnonymous]
public class LoggedOut : PageModel
{
private readonly IIdentityServerInteractionService _interactionService;
public LoggedOutViewModel View { get; set; } = default!;
public LoggedOut(IIdentityServerInteractionService interactionService)
{
_interactionService = interactionService;
}
public async Task OnGet(string? logoutId)
{
// get context information (client name, post logout redirect URI and iframe for federated signout)
var logout = await _interactionService.GetLogoutContextAsync(logoutId);
View = new LoggedOutViewModel
{
AutomaticRedirectAfterSignOut = LogoutOptions.AutomaticRedirectAfterSignOut,
PostLogoutRedirectUri = logout?.PostLogoutRedirectUri,
ClientName = String.IsNullOrEmpty(logout?.ClientName) ? logout?.ClientId : logout?.ClientName,
SignOutIframeUrl = logout?.SignOutIFrameUrl
};
}
}

@ -0,0 +1,15 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace IdentitySvc.Pages.Logout;
public class LoggedOutViewModel
{
public string? PostLogoutRedirectUri { get; set; }
public string? ClientName { get; set; }
public string? SignOutIframeUrl { get; set; }
public bool AutomaticRedirectAfterSignOut { get; set; }
}

@ -0,0 +1,11 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace IdentitySvc.Pages.Logout;
public static class LogoutOptions
{
public static readonly bool ShowLogoutPrompt = true;
public static readonly bool AutomaticRedirectAfterSignOut = false;
}

@ -0,0 +1,64 @@
@page
@model IdentitySvc.Pages.Register.Index
<!DOCTYPE html>
<html>
<head>
<title></title>
</head>
<body>
<div>
<partial name="_ValidationSummary" />
<div class="row">
<div class="col-6 offset-3">
<div class="card">
<div class="card-header">
<h2>Register</h2>
</div>
<div class="card-body">
<form asp-page="/Account/Register/Index">
<input type="hidden" asp-for="Input.ReturnUrl" />
<div class="form-group">
<label asp-for="Input.Username"></label>
<input class="form-control" placeholder="Username" asp-for="Input.Username" autofocus>
</div>
<div class="form-group">
<label asp-for="Input.Email"></label>
<input class="form-control" placeholder="Email" asp-for="Input.Email">
</div>
<div class="form-group">
<label asp-for="Input.FullName"></label>
<input class="form-control" placeholder="Full name" asp-for="Input.FullName">
</div>
<div class="form-group">
<label asp-for="Input.Password"></label>
<input type="password" class="form-control" placeholder="Password" asp-for="Input.Password" autocomplete="off">
</div>
<a class="pb-2 d-block" asp-page="../Login/Index" asp-route-returnUrl="@Model.Input.ReturnUrl">
Already Regitered? Login here
</a>
<button class="btn btn-primary" name="Input.Button" value="register">Register</button>
<button class="btn btn-secondary" name="Input.Button" value="cancel">Cancel</button>
</form>
</div>
@if (Model.RegisterSuccess)
{
<div class="alert-success mt-2">
<strong>Successfully registered</strong> - You can now login
</div>
}
</div>
</div>
</div>
</div>
</body>
</html>

@ -0,0 +1,66 @@
using System.Security.Claims;
using IdentityModel;
using IdentitySvc.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace IdentitySvc.Pages.Register;
[SecurityHeaders]
[AllowAnonymous]
public class Index : PageModel
{
private readonly UserManager<ApplicationUser> _userManager;
public Index(UserManager<ApplicationUser> userManager)
{
_userManager = userManager;
}
[BindProperty]
public RegisterViewModel Input { get; set; }
[BindProperty]
public bool RegisterSuccess { get; set; }
public IActionResult OnGet(string returnUrl)
{
Input = new RegisterViewModel
{
ReturnUrl = returnUrl,
};
return Page();
}
public async Task<IActionResult> OnPost()
{
if (Input.Button != "register") return Redirect("~/");
if (ModelState.IsValid)
{
var user = new ApplicationUser
{
UserName = Input.Username,
Email = Input.Email,
EmailConfirmed = true,
};
var result = await _userManager.CreateAsync(user, Input.Password);
if (result.Succeeded)
{
await _userManager.AddClaimsAsync(user, new Claim[]
{
new Claim(JwtClaimTypes.Name, Input.FullName)
});
RegisterSuccess = true;
}
}
return Page();
}
}

@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations;
namespace IdentitySvc.Pages.Register;
public class RegisterViewModel
{
[Required]
public string Email { get; set; }
[Required]
public string Password { get; set; }
[Required]
public string Username { get; set; }
[Required]
public string FullName { get; set; }
public string ReturnUrl { get; set; }
public string Button { get; set; }
}

@ -0,0 +1,48 @@
@page
@model IdentitySvc.Pages.Ciba.AllModel
@{
}
<div class="ciba-page">
<div class="row">
<div class="col">
<div class="card">
<div class="card-header">
<h2>Pending Backchannel Login Requests</h2>
</div>
<div class="card-body">
@if (Model.Logins.Any())
{
<table class="table table-bordered table-striped table-sm">
<thead>
<tr>
<th>Id</th>
<th>Client Id</th>
<th>Binding Message</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var login in Model.Logins)
{
<tr>
<td>@login.InternalId</td>
<td>@login.Client.ClientId</td>
<td>@login.BindingMessage</td>
<td>
<a asp-page="Consent" asp-route-id="@login.InternalId" class="btn btn-primary">Process</a>
</td>
</tr>
}
</tbody>
</table>
}
else
{
<div>No Pending Login Requests</div>
}
</div>
</div>
</div>
</div>
</div>

@ -0,0 +1,28 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace IdentitySvc.Pages.Ciba;
[SecurityHeaders]
[Authorize]
public class AllModel : PageModel
{
public IEnumerable<BackchannelUserLoginRequest> Logins { get; set; } = default!;
private readonly IBackchannelAuthenticationInteractionService _backchannelAuthenticationInteraction;
public AllModel(IBackchannelAuthenticationInteractionService backchannelAuthenticationInteractionService)
{
_backchannelAuthenticationInteraction = backchannelAuthenticationInteractionService;
}
public async Task OnGet()
{
Logins = await _backchannelAuthenticationInteraction.GetPendingLoginRequestsForCurrentUserAsync();
}
}

@ -0,0 +1,98 @@
@page
@model IdentitySvc.Pages.Ciba.Consent
@{
}
<div class="ciba-consent">
<div class="lead">
@if (Model.View.ClientLogoUrl != null)
{
<div class="client-logo"><img src="@Model.View.ClientLogoUrl"></div>
}
<h1>
@Model.View.ClientName
<small class="text-muted">is requesting your permission</small>
</h1>
<h3>Verify that this identifier matches what the client is displaying: <em class="text-primary">@Model.View.BindingMessage</em></h3>
<p>Uncheck the permissions you do not wish to grant.</p>
</div>
<div class="row">
<div class="col-sm-8">
<partial name="_ValidationSummary" />
</div>
</div>
<form asp-page="/Ciba/Consent">
<input type="hidden" asp-for="Input.Id" />
<div class="row">
<div class="col-sm-8">
@if (Model.View.IdentityScopes.Any())
{
<div class="form-group">
<div class="card">
<div class="card-header">
<span class="glyphicon glyphicon-user"></span>
Personal Information
</div>
<ul class="list-group list-group-flush">
@foreach (var scope in Model.View.IdentityScopes)
{
<partial name="_ScopeListItem" model="@scope" />
}
</ul>
</div>
</div>
}
@if (Model.View.ApiScopes.Any())
{
<div class="form-group">
<div class="card">
<div class="card-header">
<span class="glyphicon glyphicon-tasks"></span>
Application Access
</div>
<ul class="list-group list-group-flush">
@foreach (var scope in Model.View.ApiScopes)
{
<partial name="_ScopeListItem" model="scope" />
}
</ul>
</div>
</div>
}
<div class="form-group">
<div class="card">
<div class="card-header">
<span class="glyphicon glyphicon-pencil"></span>
Description
</div>
<div class="card-body">
<input class="form-control" placeholder="Description or name of device" asp-for="Input.Description" autofocus>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-sm-4">
<button name="Input.button" value="yes" class="btn btn-primary" autofocus>Yes, Allow</button>
<button name="Input.button" value="no" class="btn btn-secondary">No, Do Not Allow</button>
</div>
<div class="col-sm-4 col-lg-auto">
@if (Model.View.ClientUrl != null)
{
<a class="btn btn-outline-info" href="@Model.View.ClientUrl">
<span class="glyphicon glyphicon-info-sign"></span>
<strong>@Model.View.ClientName</strong>
</a>
}
</div>
</div>
</form>
</div>

@ -0,0 +1,228 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.IdentityServer.Events;
using Duende.IdentityServer.Extensions;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Services;
using Duende.IdentityServer.Validation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace IdentitySvc.Pages.Ciba;
[Authorize]
[SecurityHeaders]
public class Consent : PageModel
{
private readonly IBackchannelAuthenticationInteractionService _interaction;
private readonly IEventService _events;
private readonly ILogger<Consent> _logger;
public Consent(
IBackchannelAuthenticationInteractionService interaction,
IEventService events,
ILogger<Consent> logger)
{
_interaction = interaction;
_events = events;
_logger = logger;
}
public ViewModel View { get; set; } = default!;
[BindProperty]
public InputModel Input { get; set; } = default!;
public async Task<IActionResult> OnGet(string? id)
{
if (!await SetViewModelAsync(id))
{
return RedirectToPage("/Home/Error/Index");
}
Input = new InputModel
{
Id = id
};
return Page();
}
public async Task<IActionResult> OnPost()
{
// validate return url is still valid
var request = await _interaction.GetLoginRequestByInternalIdAsync(Input.Id ?? throw new ArgumentNullException(nameof(Input.Id)));
if (request == null || request.Subject.GetSubjectId() != User.GetSubjectId())
{
_logger.InvalidId(Input.Id);
return RedirectToPage("/Home/Error/Index");
}
CompleteBackchannelLoginRequest? result = null;
// user clicked 'no' - send back the standard 'access_denied' response
if (Input.Button == "no")
{
result = new CompleteBackchannelLoginRequest(Input.Id);
// emit event
await _events.RaiseAsync(new ConsentDeniedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues));
Telemetry.Metrics.ConsentDenied(request.Client.ClientId, request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName));
}
// user clicked 'yes' - validate the data
else if (Input.Button == "yes")
{
// if the user consented to some scope, build the response model
if (Input.ScopesConsented.Any())
{
var scopes = Input.ScopesConsented;
if (ConsentOptions.EnableOfflineAccess == false)
{
scopes = scopes.Where(x => x != Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess);
}
result = new CompleteBackchannelLoginRequest(Input.Id)
{
ScopesValuesConsented = scopes.ToArray(),
Description = Input.Description
};
// emit event
await _events.RaiseAsync(new ConsentGrantedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues, result.ScopesValuesConsented, false));
Telemetry.Metrics.ConsentGranted(request.Client.ClientId, result.ScopesValuesConsented, false);
var denied = request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName).Except(result.ScopesValuesConsented);
Telemetry.Metrics.ConsentDenied(request.Client.ClientId, denied);
}
else
{
ModelState.AddModelError("", ConsentOptions.MustChooseOneErrorMessage);
}
}
else
{
ModelState.AddModelError("", ConsentOptions.InvalidSelectionErrorMessage);
}
if (result != null)
{
// communicate outcome of consent back to identityserver
await _interaction.CompleteLoginRequestAsync(result);
return RedirectToPage("/Ciba/All");
}
// we need to redisplay the consent UI
if (!await SetViewModelAsync(Input.Id))
{
return RedirectToPage("/Home/Error/Index");
}
return Page();
}
private async Task<bool> SetViewModelAsync(string? id)
{
ArgumentNullException.ThrowIfNull(id);
var request = await _interaction.GetLoginRequestByInternalIdAsync(id);
if (request != null && request.Subject.GetSubjectId() == User.GetSubjectId())
{
View = CreateConsentViewModel(request);
return true;
}
else
{
_logger.NoMatchingBackchannelLoginRequest(id);
return false;
}
}
private ViewModel CreateConsentViewModel(BackchannelUserLoginRequest request)
{
var vm = new ViewModel
{
ClientName = request.Client.ClientName ?? request.Client.ClientId,
ClientUrl = request.Client.ClientUri,
ClientLogoUrl = request.Client.LogoUri,
BindingMessage = request.BindingMessage
};
vm.IdentityScopes = request.ValidatedResources.Resources.IdentityResources
.Select(x => CreateScopeViewModel(x, Input == null || Input.ScopesConsented.Contains(x.Name)))
.ToArray();
var resourceIndicators = request.RequestedResourceIndicators ?? Enumerable.Empty<string>();
var apiResources = request.ValidatedResources.Resources.ApiResources.Where(x => resourceIndicators.Contains(x.Name));
var apiScopes = new List<ScopeViewModel>();
foreach (var parsedScope in request.ValidatedResources.ParsedScopes)
{
var apiScope = request.ValidatedResources.Resources.FindApiScope(parsedScope.ParsedName);
if (apiScope != null)
{
var scopeVm = CreateScopeViewModel(parsedScope, apiScope, Input == null || Input.ScopesConsented.Contains(parsedScope.RawValue));
scopeVm.Resources = apiResources.Where(x => x.Scopes.Contains(parsedScope.ParsedName))
.Select(x => new ResourceViewModel
{
Name = x.Name,
DisplayName = x.DisplayName ?? x.Name,
}).ToArray();
apiScopes.Add(scopeVm);
}
}
if (ConsentOptions.EnableOfflineAccess && request.ValidatedResources.Resources.OfflineAccess)
{
apiScopes.Add(GetOfflineAccessScope(Input == null || Input.ScopesConsented.Contains(Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess)));
}
vm.ApiScopes = apiScopes;
return vm;
}
private static ScopeViewModel CreateScopeViewModel(IdentityResource identity, bool check)
{
return new ScopeViewModel
{
Name = identity.Name,
Value = identity.Name,
DisplayName = identity.DisplayName ?? identity.Name,
Description = identity.Description,
Emphasize = identity.Emphasize,
Required = identity.Required,
Checked = check || identity.Required
};
}
private static ScopeViewModel CreateScopeViewModel(ParsedScopeValue parsedScopeValue, ApiScope apiScope, bool check)
{
var displayName = apiScope.DisplayName ?? apiScope.Name;
if (!String.IsNullOrWhiteSpace(parsedScopeValue.ParsedParameter))
{
displayName += ":" + parsedScopeValue.ParsedParameter;
}
return new ScopeViewModel
{
Name = parsedScopeValue.ParsedName,
Value = parsedScopeValue.RawValue,
DisplayName = displayName,
Description = apiScope.Description,
Emphasize = apiScope.Emphasize,
Required = apiScope.Required,
Checked = check || apiScope.Required
};
}
private static ScopeViewModel GetOfflineAccessScope(bool check)
{
return new ScopeViewModel
{
Value = Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess,
DisplayName = ConsentOptions.OfflineAccessDisplayName,
Description = ConsentOptions.OfflineAccessDescription,
Emphasize = true,
Checked = check
};
}
}

@ -0,0 +1,14 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace IdentitySvc.Pages.Ciba;
public static class ConsentOptions
{
public static readonly bool EnableOfflineAccess = true;
public static readonly string OfflineAccessDisplayName = "Offline Access";
public static readonly string OfflineAccessDescription = "Access to your applications and resources, even when you are offline";
public static readonly string MustChooseOneErrorMessage = "You must pick at least one permission";
public static readonly string InvalidSelectionErrorMessage = "Invalid selection";
}

@ -0,0 +1,30 @@
@page
@model IdentitySvc.Pages.Ciba.IndexModel
@{
}
<div class="ciba-page">
<div class="lead">
@if (Model.LoginRequest.Client.LogoUri != null)
{
<div class="client-logo"><img src="@Model.LoginRequest.Client.LogoUri"></div>
}
<h1>
@Model.LoginRequest.Client.ClientName
<small class="text-muted">is requesting your permission</small>
</h1>
<h3>
Verify that this identifier matches what the client is displaying:
<em class="text-primary">@Model.LoginRequest.BindingMessage</em>
</h3>
<p>
Do you wish to continue?
</p>
<div>
<a class="btn btn-primary" asp-page="/Ciba/Consent" asp-route-id="@Model.LoginRequest.InternalId">Yes, Continue</a>
</div>
</div>
</div>

@ -0,0 +1,42 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace IdentitySvc.Pages.Ciba;
[AllowAnonymous]
[SecurityHeaders]
public class IndexModel : PageModel
{
public BackchannelUserLoginRequest LoginRequest { get; set; } = default!;
private readonly IBackchannelAuthenticationInteractionService _backchannelAuthenticationInteraction;
private readonly ILogger<IndexModel> _logger;
public IndexModel(IBackchannelAuthenticationInteractionService backchannelAuthenticationInteractionService, ILogger<IndexModel> logger)
{
_backchannelAuthenticationInteraction = backchannelAuthenticationInteractionService;
_logger = logger;
}
public async Task<IActionResult> OnGet(string id)
{
var result = await _backchannelAuthenticationInteraction.GetLoginRequestByInternalIdAsync(id);
if (result == null)
{
_logger.InvalidBackchannelLoginId(id);
return RedirectToPage("/Home/Error/Index");
}
else
{
LoginRequest = result;
}
return Page();
}
}

@ -0,0 +1,12 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace IdentitySvc.Pages.Ciba;
public class InputModel
{
public string? Button { get; set; }
public IEnumerable<string> ScopesConsented { get; set; } = new List<string>();
public string? Id { get; set; }
public string? Description { get; set; }
}

@ -0,0 +1,34 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace IdentitySvc.Pages.Ciba;
public class ViewModel
{
public string? ClientName { get; set; }
public string? ClientUrl { get; set; }
public string? ClientLogoUrl { get; set; }
public string? BindingMessage { get; set; }
public IEnumerable<ScopeViewModel> IdentityScopes { get; set; } = Enumerable.Empty<ScopeViewModel>();
public IEnumerable<ScopeViewModel> ApiScopes { get; set; } = Enumerable.Empty<ScopeViewModel>();
}
public class ScopeViewModel
{
public string? Name { get; set; }
public string? Value { get; set; }
public string? DisplayName { get; set; }
public string? Description { get; set; }
public bool Emphasize { get; set; }
public bool Required { get; set; }
public bool Checked { get; set; }
public IEnumerable<ResourceViewModel> Resources { get; set; } = Enumerable.Empty<ResourceViewModel>();
}
public class ResourceViewModel
{
public string? Name { get; set; }
public string? DisplayName { get; set; }
}

@ -0,0 +1,47 @@
@using IdentitySvc.Pages.Ciba
@model ScopeViewModel
<li class="list-group-item">
<label>
<input class="consent-scopecheck"
type="checkbox"
name="Input.ScopesConsented"
id="scopes_@Model.Value"
value="@Model.Value"
checked="@Model.Checked"
disabled="@Model.Required" />
@if (Model.Required)
{
<input type="hidden"
name="Input.ScopesConsented"
value="@Model.Value" />
}
<strong>@Model.DisplayName</strong>
@if (Model.Emphasize)
{
<span class="glyphicon glyphicon-exclamation-sign"></span>
}
</label>
@if (Model.Required)
{
<span><em>(required)</em></span>
}
@if (Model.Description != null)
{
<div class="consent-description">
<label for="scopes_@Model.Value">@Model.Description</label>
</div>
}
@if (Model.Resources?.Any() == true)
{
<div class="consent-description">
<label>Will be available to these resource servers:</label>
<ul>
@foreach (var resource in Model.Resources)
{
<li>@resource.DisplayName</li>
}
</ul>
</div>
}
</li>

@ -0,0 +1,14 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace IdentitySvc.Pages.Consent;
public static class ConsentOptions
{
public static readonly bool EnableOfflineAccess = true;
public static readonly string OfflineAccessDisplayName = "Offline Access";
public static readonly string OfflineAccessDescription = "Access to your applications and resources, even when you are offline";
public static readonly string MustChooseOneErrorMessage = "You must pick at least one permission";
public static readonly string InvalidSelectionErrorMessage = "Invalid selection";
}

@ -0,0 +1,107 @@
@page
@model IdentitySvc.Pages.Consent.Index
@{
}
<div class="page-consent">
<div class="lead">
@if (Model.View.ClientLogoUrl != null)
{
<div class="client-logo"><img src="@Model.View.ClientLogoUrl"></div>
}
<h1>
@Model.View.ClientName
<small class="text-muted">is requesting your permission</small>
</h1>
<p>Uncheck the permissions you do not wish to grant.</p>
</div>
<div class="row">
<div class="col-sm-8">
<partial name="_ValidationSummary" />
</div>
</div>
<form asp-page="/Consent/Index">
<input type="hidden" asp-for="Input.ReturnUrl" />
<div class="row">
<div class="col-sm-8">
@if (Model.View.IdentityScopes.Any())
{
<div class="form-group">
<div class="card">
<div class="card-header">
<span class="glyphicon glyphicon-user"></span>
Personal Information
</div>
<ul class="list-group list-group-flush">
@foreach (var scope in Model.View.IdentityScopes)
{
<partial name="_ScopeListItem" model="@scope" />
}
</ul>
</div>
</div>
}
@if (Model.View.ApiScopes.Any())
{
<div class="form-group">
<div class="card">
<div class="card-header">
<span class="glyphicon glyphicon-tasks"></span>
Application Access
</div>
<ul class="list-group list-group-flush">
@foreach (var scope in Model.View.ApiScopes)
{
<partial name="_ScopeListItem" model="scope" />
}
</ul>
</div>
</div>
}
<div class="form-group">
<div class="card">
<div class="card-header">
<span class="glyphicon glyphicon-pencil"></span>
Description
</div>
<div class="card-body">
<input class="form-control" placeholder="Description or name of device" asp-for="Input.Description" autofocus>
</div>
</div>
</div>
@if (Model.View.AllowRememberConsent)
{
<div class="form-group">
<div class="form-check">
<input class="form-check-input" asp-for="Input.RememberConsent">
<label class="form-check-label" asp-for="Input.RememberConsent">
<strong>Remember My Decision</strong>
</label>
</div>
</div>
}
</div>
</div>
<div class="row">
<div class="col-sm-4">
<button name="Input.button" value="yes" class="btn btn-primary" autofocus>Yes, Allow</button>
<button name="Input.button" value="no" class="btn btn-secondary">No, Do Not Allow</button>
</div>
<div class="col-sm-4 col-lg-auto">
@if (Model.View.ClientUrl != null)
{
<a class="btn btn-outline-info" href="@Model.View.ClientUrl">
<span class="glyphicon glyphicon-info-sign"></span>
<strong>@Model.View.ClientName</strong>
</a>
}
</div>
</div>
</form>
</div>

@ -0,0 +1,236 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.IdentityServer.Events;
using Duende.IdentityServer.Extensions;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Services;
using Duende.IdentityServer.Validation;
using IdentityModel;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace IdentitySvc.Pages.Consent;
[Authorize]
[SecurityHeaders]
public class Index : PageModel
{
private readonly IIdentityServerInteractionService _interaction;
private readonly IEventService _events;
private readonly ILogger<Index> _logger;
public Index(
IIdentityServerInteractionService interaction,
IEventService events,
ILogger<Index> logger)
{
_interaction = interaction;
_events = events;
_logger = logger;
}
public ViewModel View { get; set; } = default!;
[BindProperty]
public InputModel Input { get; set; } = default!;
public async Task<IActionResult> OnGet(string? returnUrl)
{
if (!await SetViewModelAsync(returnUrl))
{
return RedirectToPage("/Home/Error/Index");
}
Input = new InputModel
{
ReturnUrl = returnUrl,
};
return Page();
}
public async Task<IActionResult> OnPost()
{
// validate return url is still valid
var request = await _interaction.GetAuthorizationContextAsync(Input.ReturnUrl);
if (request == null) return RedirectToPage("/Home/Error/Index");
ConsentResponse? grantedConsent = null;
// user clicked 'no' - send back the standard 'access_denied' response
if (Input.Button == "no")
{
grantedConsent = new ConsentResponse { Error = AuthorizationError.AccessDenied };
// emit event
await _events.RaiseAsync(new ConsentDeniedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues));
Telemetry.Metrics.ConsentDenied(request.Client.ClientId, request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName));
}
// user clicked 'yes' - validate the data
else if (Input.Button == "yes")
{
// if the user consented to some scope, build the response model
if (Input.ScopesConsented.Any())
{
var scopes = Input.ScopesConsented;
if (ConsentOptions.EnableOfflineAccess == false)
{
scopes = scopes.Where(x => x != Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess);
}
grantedConsent = new ConsentResponse
{
RememberConsent = Input.RememberConsent,
ScopesValuesConsented = scopes.ToArray(),
Description = Input.Description
};
// emit event
await _events.RaiseAsync(new ConsentGrantedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent));
Telemetry.Metrics.ConsentGranted(request.Client.ClientId, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent);
var denied = request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName).Except(grantedConsent.ScopesValuesConsented);
Telemetry.Metrics.ConsentDenied(request.Client.ClientId, denied);
}
else
{
ModelState.AddModelError("", ConsentOptions.MustChooseOneErrorMessage);
}
}
else
{
ModelState.AddModelError("", ConsentOptions.InvalidSelectionErrorMessage);
}
if (grantedConsent != null)
{
ArgumentNullException.ThrowIfNull(Input.ReturnUrl, nameof(Input.ReturnUrl));
// communicate outcome of consent back to identityserver
await _interaction.GrantConsentAsync(request, grantedConsent);
// redirect back to authorization endpoint
if (request.IsNativeClient() == true)
{
// The client is native, so this change in how to
// return the response is for better UX for the end user.
return this.LoadingPage(Input.ReturnUrl);
}
return Redirect(Input.ReturnUrl);
}
// we need to redisplay the consent UI
if (!await SetViewModelAsync(Input.ReturnUrl))
{
return RedirectToPage("/Home/Error/Index");
}
return Page();
}
private async Task<bool> SetViewModelAsync(string? returnUrl)
{
ArgumentNullException.ThrowIfNull(returnUrl);
var request = await _interaction.GetAuthorizationContextAsync(returnUrl);
if (request != null)
{
View = CreateConsentViewModel(request);
return true;
}
else
{
_logger.NoConsentMatchingRequest(returnUrl);
return false;
}
}
private ViewModel CreateConsentViewModel(AuthorizationRequest request)
{
var vm = new ViewModel
{
ClientName = request.Client.ClientName ?? request.Client.ClientId,
ClientUrl = request.Client.ClientUri,
ClientLogoUrl = request.Client.LogoUri,
AllowRememberConsent = request.Client.AllowRememberConsent
};
vm.IdentityScopes = request.ValidatedResources.Resources.IdentityResources
.Select(x => CreateScopeViewModel(x, Input == null || Input.ScopesConsented.Contains(x.Name)))
.ToArray();
var resourceIndicators = request.Parameters.GetValues(OidcConstants.AuthorizeRequest.Resource) ?? Enumerable.Empty<string>();
var apiResources = request.ValidatedResources.Resources.ApiResources.Where(x => resourceIndicators.Contains(x.Name));
var apiScopes = new List<ScopeViewModel>();
foreach (var parsedScope in request.ValidatedResources.ParsedScopes)
{
var apiScope = request.ValidatedResources.Resources.FindApiScope(parsedScope.ParsedName);
if (apiScope != null)
{
var scopeVm = CreateScopeViewModel(parsedScope, apiScope, Input == null || Input.ScopesConsented.Contains(parsedScope.RawValue));
scopeVm.Resources = apiResources.Where(x => x.Scopes.Contains(parsedScope.ParsedName))
.Select(x => new ResourceViewModel
{
Name = x.Name,
DisplayName = x.DisplayName ?? x.Name,
}).ToArray();
apiScopes.Add(scopeVm);
}
}
if (ConsentOptions.EnableOfflineAccess && request.ValidatedResources.Resources.OfflineAccess)
{
apiScopes.Add(CreateOfflineAccessScope(Input == null || Input.ScopesConsented.Contains(Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess)));
}
vm.ApiScopes = apiScopes;
return vm;
}
private static ScopeViewModel CreateScopeViewModel(IdentityResource identity, bool check)
{
return new ScopeViewModel
{
Name = identity.Name,
Value = identity.Name,
DisplayName = identity.DisplayName ?? identity.Name,
Description = identity.Description,
Emphasize = identity.Emphasize,
Required = identity.Required,
Checked = check || identity.Required
};
}
private static ScopeViewModel CreateScopeViewModel(ParsedScopeValue parsedScopeValue, ApiScope apiScope, bool check)
{
var displayName = apiScope.DisplayName ?? apiScope.Name;
if (!String.IsNullOrWhiteSpace(parsedScopeValue.ParsedParameter))
{
displayName += ":" + parsedScopeValue.ParsedParameter;
}
return new ScopeViewModel
{
Name = parsedScopeValue.ParsedName,
Value = parsedScopeValue.RawValue,
DisplayName = displayName,
Description = apiScope.Description,
Emphasize = apiScope.Emphasize,
Required = apiScope.Required,
Checked = check || apiScope.Required
};
}
private static ScopeViewModel CreateOfflineAccessScope(bool check)
{
return new ScopeViewModel
{
Value = Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess,
DisplayName = ConsentOptions.OfflineAccessDisplayName,
Description = ConsentOptions.OfflineAccessDescription,
Emphasize = true,
Checked = check
};
}
}

@ -0,0 +1,13 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace IdentitySvc.Pages.Consent;
public class InputModel
{
public string? Button { get; set; }
public IEnumerable<string> ScopesConsented { get; set; } = new List<string>();
public bool RememberConsent { get; set; } = true;
public string? ReturnUrl { get; set; }
public string? Description { get; set; }
}

@ -0,0 +1,33 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace IdentitySvc.Pages.Consent;
public class ViewModel
{
public string? ClientName { get; set; }
public string? ClientUrl { get; set; }
public string? ClientLogoUrl { get; set; }
public bool AllowRememberConsent { get; set; }
public IEnumerable<ScopeViewModel> IdentityScopes { get; set; } = Enumerable.Empty<ScopeViewModel>();
public IEnumerable<ScopeViewModel> ApiScopes { get; set; } = Enumerable.Empty<ScopeViewModel>();
}
public class ScopeViewModel
{
public string? Name { get; set; }
public string? Value { get; set; }
public string? DisplayName { get; set; }
public string? Description { get; set; }
public bool Emphasize { get; set; }
public bool Required { get; set; }
public bool Checked { get; set; }
public IEnumerable<ResourceViewModel> Resources { get; set; } = Enumerable.Empty<ResourceViewModel>();
}
public class ResourceViewModel
{
public string? Name { get; set; }
public string? DisplayName { get; set; }
}

@ -0,0 +1,47 @@
@using IdentitySvc.Pages.Consent
@model ScopeViewModel
<li class="list-group-item">
<label>
<input class="consent-scopecheck"
type="checkbox"
name="Input.ScopesConsented"
id="scopes_@Model.Value"
value="@Model.Value"
checked="@Model.Checked"
disabled="@Model.Required" />
@if (Model.Required)
{
<input type="hidden"
name="Input.ScopesConsented"
value="@Model.Value" />
}
<strong>@Model.DisplayName</strong>
@if (Model.Emphasize)
{
<span class="glyphicon glyphicon-exclamation-sign"></span>
}
</label>
@if (Model.Required)
{
<span><em>(required)</em></span>
}
@if (Model.Description != null)
{
<div class="consent-description">
<label for="scopes_@Model.Value">@Model.Description</label>
</div>
}
@if (Model.Resources?.Any() == true)
{
<div class="consent-description">
<label>Will be available to these resource servers:</label>
<ul>
@foreach (var resource in Model.Resources)
{
<li>@resource.DisplayName</li>
}
</ul>
</div>
}
</li>

@ -0,0 +1,15 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace IdentitySvc.Pages.Device;
public static class DeviceOptions
{
public static readonly bool EnableOfflineAccess = true;
public static readonly string OfflineAccessDisplayName = "Offline Access";
public static readonly string OfflineAccessDescription = "Access to your applications and resources, even when you are offline";
public static readonly string InvalidUserCode = "Invalid user code";
public static readonly string MustChooseOneErrorMessage = "You must pick at least one permission";
public static readonly string InvalidSelectionErrorMessage = "Invalid selection";
}

@ -0,0 +1,141 @@
@page
@model IdentitySvc.Pages.Device.Index
@{
}
@if (Model.Input.UserCode == null)
{
@*We need to collect the user code*@
<div class="page-device-code">
<div class="lead">
<h1>User Code</h1>
<p>Please enter the code displayed on your device.</p>
</div>
<div class="row">
<div class="col-sm-8">
<partial name="_ValidationSummary" />
</div>
</div>
<div class="row">
<div class="col-sm-6">
<form asp-page="/Device/Index" method="get">
<div class="form-group">
<label for="userCode">User Code:</label>
<input class="form-control" for="userCode" name="userCode" autofocus />
</div>
<button class="btn btn-primary" name="button">Submit</button>
</form>
</div>
</div>
</div>
}
else
{
@*collect consent for the user code provided*@
<div class="page-device-confirmation">
<div class="lead">
@if (Model.View.ClientLogoUrl != null)
{
<div class="client-logo"><img src="@Model.View.ClientLogoUrl"></div>
}
<h1>
@Model.View.ClientName
<small class="text-muted">is requesting your permission</small>
</h1>
<p>Please confirm that the authorization request matches the code: <strong>@Model.Input.UserCode</strong>.</p>
<p>Uncheck the permissions you do not wish to grant.</p>
</div>
<div class="row">
<div class="col-sm-8">
<partial name="_ValidationSummary" />
</div>
</div>
<form asp-page="/Device/Index">
<input asp-for="Input.UserCode" type="hidden" />
<div class="row">
<div class="col-sm-8">
@if (Model.View.IdentityScopes.Any())
{
<div class="form-group">
<div class="card">
<div class="card-header">
<span class="glyphicon glyphicon-user"></span>
Personal Information
</div>
<ul class="list-group list-group-flush">
@foreach (var scope in Model.View.IdentityScopes)
{
<partial name="_ScopeListItem" model="@scope" />
}
</ul>
</div>
</div>
}
@if (Model.View.ApiScopes.Any())
{
<div class="form-group">
<div class="card">
<div class="card-header">
<span class="glyphicon glyphicon-tasks"></span>
Application Access
</div>
<ul class="list-group list-group-flush">
@foreach (var scope in Model.View.ApiScopes)
{
<partial name="_ScopeListItem" model="scope" />
}
</ul>
</div>
</div>
}
<div class="form-group">
<div class="card">
<div class="card-header">
<span class="glyphicon glyphicon-pencil"></span>
Description
</div>
<div class="card-body">
<input class="form-control" placeholder="Description or name of device" asp-for="Input.Description" autofocus>
</div>
</div>
</div>
@if (Model.View.AllowRememberConsent)
{
<div class="form-group">
<div class="form-check">
<input class="form-check-input" asp-for="Input.RememberConsent">
<label class="form-check-label" asp-for="Input.RememberConsent">
<strong>Remember My Decision</strong>
</label>
</div>
</div>
}
</div>
</div>
<div class="row">
<div class="col-sm-4">
<button name="Input.button" value="yes" class="btn btn-primary" autofocus>Yes, Allow</button>
<button name="Input.button" value="no" class="btn btn-secondary">No, Do Not Allow</button>
</div>
<div class="col-sm-4 col-lg-auto">
@if (Model.View.ClientUrl != null)
{
<a class="btn btn-outline-info" href="@Model.View.ClientUrl">
<span class="glyphicon glyphicon-info-sign"></span>
<strong>@Model.View.ClientName</strong>
</a>
}
</div>
</div>
</form>
</div>
}

@ -0,0 +1,220 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.IdentityServer.Configuration;
using Duende.IdentityServer.Events;
using Duende.IdentityServer.Extensions;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Services;
using Duende.IdentityServer.Validation;
using IdentitySvc.Pages.Consent;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Options;
namespace IdentitySvc.Pages.Device;
[SecurityHeaders]
[Authorize]
public class Index : PageModel
{
private readonly IDeviceFlowInteractionService _interaction;
private readonly IEventService _events;
private readonly IOptions<IdentityServerOptions> _options;
private readonly ILogger<Index> _logger;
public Index(
IDeviceFlowInteractionService interaction,
IEventService eventService,
IOptions<IdentityServerOptions> options,
ILogger<Index> logger)
{
_interaction = interaction;
_events = eventService;
_options = options;
_logger = logger;
}
public ViewModel View { get; set; } = default!;
[BindProperty]
public InputModel Input { get; set; } = default!;
public async Task<IActionResult> OnGet(string? userCode)
{
if (String.IsNullOrWhiteSpace(userCode))
{
return Page();
}
if (!await SetViewModelAsync(userCode))
{
ModelState.AddModelError("", DeviceOptions.InvalidUserCode);
return Page();
}
Input = new InputModel {
UserCode = userCode,
};
return Page();
}
public async Task<IActionResult> OnPost()
{
var request = await _interaction.GetAuthorizationContextAsync(Input.UserCode ?? throw new ArgumentNullException(nameof(Input.UserCode)));
if (request == null) return RedirectToPage("/Home/Error/Index");
ConsentResponse? grantedConsent = null;
// user clicked 'no' - send back the standard 'access_denied' response
if (Input.Button == "no")
{
grantedConsent = new ConsentResponse
{
Error = AuthorizationError.AccessDenied
};
// emit event
await _events.RaiseAsync(new ConsentDeniedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues));
Telemetry.Metrics.ConsentDenied(request.Client.ClientId, request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName));
}
// user clicked 'yes' - validate the data
else if (Input.Button == "yes")
{
// if the user consented to some scope, build the response model
if (Input.ScopesConsented.Any())
{
var scopes = Input.ScopesConsented;
if (ConsentOptions.EnableOfflineAccess == false)
{
scopes = scopes.Where(x => x != Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess);
}
grantedConsent = new ConsentResponse
{
RememberConsent = Input.RememberConsent,
ScopesValuesConsented = scopes.ToArray(),
Description = Input.Description
};
// emit event
await _events.RaiseAsync(new ConsentGrantedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent));
Telemetry.Metrics.ConsentGranted(request.Client.ClientId, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent);
var denied = request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName).Except(grantedConsent.ScopesValuesConsented);
Telemetry.Metrics.ConsentDenied(request.Client.ClientId, denied);
}
else
{
ModelState.AddModelError("", ConsentOptions.MustChooseOneErrorMessage);
}
}
else
{
ModelState.AddModelError("", ConsentOptions.InvalidSelectionErrorMessage);
}
if (grantedConsent != null)
{
// communicate outcome of consent back to identityserver
await _interaction.HandleRequestAsync(Input.UserCode, grantedConsent);
// indicate that's it ok to redirect back to authorization endpoint
return RedirectToPage("/Device/Success");
}
// we need to redisplay the consent UI
if (!await SetViewModelAsync(Input.UserCode))
{
return RedirectToPage("/Home/Error/Index");
}
return Page();
}
private async Task<bool> SetViewModelAsync(string userCode)
{
var request = await _interaction.GetAuthorizationContextAsync(userCode);
if (request != null)
{
View = CreateConsentViewModel(request);
return true;
}
else
{
View = new ViewModel();
return false;
}
}
private ViewModel CreateConsentViewModel(DeviceFlowAuthorizationRequest request)
{
var vm = new ViewModel
{
ClientName = request.Client.ClientName ?? request.Client.ClientId,
ClientUrl = request.Client.ClientUri,
ClientLogoUrl = request.Client.LogoUri,
AllowRememberConsent = request.Client.AllowRememberConsent
};
vm.IdentityScopes = request.ValidatedResources.Resources.IdentityResources.Select(x => CreateScopeViewModel(x, Input == null || Input.ScopesConsented.Contains(x.Name))).ToArray();
var apiScopes = new List<ScopeViewModel>();
foreach (var parsedScope in request.ValidatedResources.ParsedScopes)
{
var apiScope = request.ValidatedResources.Resources.FindApiScope(parsedScope.ParsedName);
if (apiScope != null)
{
var scopeVm = CreateScopeViewModel(parsedScope, apiScope, Input == null || Input.ScopesConsented.Contains(parsedScope.RawValue));
apiScopes.Add(scopeVm);
}
}
if (DeviceOptions.EnableOfflineAccess && request.ValidatedResources.Resources.OfflineAccess)
{
apiScopes.Add(GetOfflineAccessScope(Input == null || Input.ScopesConsented.Contains(Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess)));
}
vm.ApiScopes = apiScopes;
return vm;
}
private static ScopeViewModel CreateScopeViewModel(IdentityResource identity, bool check)
{
return new ScopeViewModel
{
Value = identity.Name,
DisplayName = identity.DisplayName ?? identity.Name,
Description = identity.Description,
Emphasize = identity.Emphasize,
Required = identity.Required,
Checked = check || identity.Required
};
}
private static ScopeViewModel CreateScopeViewModel(ParsedScopeValue parsedScopeValue, ApiScope apiScope, bool check)
{
return new ScopeViewModel
{
Value = parsedScopeValue.RawValue,
// todo: use the parsed scope value in the display?
DisplayName = apiScope.DisplayName ?? apiScope.Name,
Description = apiScope.Description,
Emphasize = apiScope.Emphasize,
Required = apiScope.Required,
Checked = check || apiScope.Required
};
}
private static ScopeViewModel GetOfflineAccessScope(bool check)
{
return new ScopeViewModel
{
Value = Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess,
DisplayName = DeviceOptions.OfflineAccessDisplayName,
Description = DeviceOptions.OfflineAccessDescription,
Emphasize = true,
Checked = check
};
}
}

@ -0,0 +1,14 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace IdentitySvc.Pages.Device;
public class InputModel
{
public string? Button { get; set; }
public IEnumerable<string> ScopesConsented { get; set; } = new List<string>();
public bool RememberConsent { get; set; } = true;
public string? ReturnUrl { get; set; }
public string? Description { get; set; }
public string? UserCode { get; set; }
}

@ -0,0 +1,12 @@
@page
@model IdentitySvc.Pages.Device.SuccessModel
@{
}
<div class="page-device-success">
<div class="lead">
<h1>Success</h1>
<p>You have successfully authorized the device</p>
</div>
</div>

@ -0,0 +1,16 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace IdentitySvc.Pages.Device;
[SecurityHeaders]
[Authorize]
public class SuccessModel : PageModel
{
public void OnGet()
{
}
}

@ -0,0 +1,25 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace IdentitySvc.Pages.Device;
public class ViewModel
{
public string? ClientName { get; set; }
public string? ClientUrl { get; set; }
public string? ClientLogoUrl { get; set; }
public bool AllowRememberConsent { get; set; }
public IEnumerable<ScopeViewModel> IdentityScopes { get; set; } = Enumerable.Empty<ScopeViewModel>();
public IEnumerable<ScopeViewModel> ApiScopes { get; set; } = Enumerable.Empty<ScopeViewModel>();
}
public class ScopeViewModel
{
public string? Value { get; set; }
public string? DisplayName { get; set; }
public string? Description { get; set; }
public bool Emphasize { get; set; }
public bool Required { get; set; }
public bool Checked { get; set; }
}

@ -0,0 +1,35 @@
@using IdentitySvc.Pages.Device
@model ScopeViewModel
<li class="list-group-item">
<label>
<input class="consent-scopecheck"
type="checkbox"
name="Input.ScopesConsented"
id="scopes_@Model.Value"
value="@Model.Value"
checked="@Model.Checked"
disabled="@Model.Required" />
@if (Model.Required)
{
<input type="hidden"
name="Input.ScopesConsented"
value="@Model.Value" />
}
<strong>@Model.DisplayName</strong>
@if (Model.Emphasize)
{
<span class="glyphicon glyphicon-exclamation-sign"></span>
}
</label>
@if (Model.Required)
{
<span><em>(required)</em></span>
}
@if (Model.Description != null)
{
<div class="consent-description">
<label for="scopes_@Model.Value">@Model.Description</label>
</div>
}
</li>

@ -0,0 +1,67 @@
@page
@model IdentitySvc.Pages.Diagnostics.Index
<div class="diagnostics-page">
<div class="lead">
<h1>Authentication Cookie</h1>
</div>
<div class="row">
<div class="col">
<div class="card">
<div class="card-header">
<h2>Claims</h2>
</div>
<div class="card-body">
@if(Model.View.AuthenticateResult.Principal != null)
{
<dl>
@foreach (var claim in Model.View.AuthenticateResult.Principal.Claims)
{
<dt>@claim.Type</dt>
<dd>@claim.Value</dd>
}
</dl>
}
</div>
</div>
</div>
<div class="col">
<div class="card">
<div class="card-header">
<h2>Properties</h2>
</div>
<div class="card-body">
<dl>
@if (Model.View.AuthenticateResult.Properties != null)
{
@foreach (var prop in Model.View.AuthenticateResult.Properties.Items)
{
<dt>@prop.Key</dt>
<dd>@prop.Value</dd>
}
}
@if (Model.View.Clients.Any())
{
<dt>Clients</dt>
<dd>
@{
var clients = Model.View.Clients.ToArray();
for(var i = 0; i < clients.Length; i++)
{
<text>@clients[i]</text>
if (i < clients.Length - 1)
{
<text>, </text>
}
}
}
</dd>
}
</dl>
</div>
</div>
</div>
</div>
</div>

@ -0,0 +1,34 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Authorization;
namespace IdentitySvc.Pages.Diagnostics;
[SecurityHeaders]
[Authorize]
public class Index : PageModel
{
public ViewModel View { get; set; } = default!;
public async Task<IActionResult> OnGet()
{
var localAddresses = new List<string?> { "127.0.0.1", "::1" };
if(HttpContext.Connection.LocalIpAddress != null)
{
localAddresses.Add(HttpContext.Connection.LocalIpAddress.ToString());
}
if (!localAddresses.Contains(HttpContext.Connection.RemoteIpAddress?.ToString()))
{
return NotFound();
}
View = new ViewModel(await HttpContext.AuthenticateAsync());
return Page();
}
}

@ -0,0 +1,32 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using IdentityModel;
using Microsoft.AspNetCore.Authentication;
using System.Text;
using System.Text.Json;
namespace IdentitySvc.Pages.Diagnostics;
public class ViewModel
{
public ViewModel(AuthenticateResult result)
{
AuthenticateResult = result;
if (result?.Properties?.Items.TryGetValue("client_list", out var encoded) == true)
{
if (encoded != null)
{
var bytes = Base64Url.Decode(encoded);
var value = Encoding.UTF8.GetString(bytes);
Clients = JsonSerializer.Deserialize<string[]>(value) ?? Enumerable.Empty<string>();
return;
}
}
Clients = Enumerable.Empty<string>();
}
public AuthenticateResult AuthenticateResult { get; }
public IEnumerable<string> Clients { get; }
}

@ -0,0 +1,42 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.IdentityServer.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace IdentitySvc.Pages;
public static class Extensions
{
/// <summary>
/// Determines if the authentication scheme support signout.
/// </summary>
internal static async Task<bool> GetSchemeSupportsSignOutAsync(this HttpContext context, string scheme)
{
var provider = context.RequestServices.GetRequiredService<IAuthenticationHandlerProvider>();
var handler = await provider.GetHandlerAsync(context, scheme);
return (handler is IAuthenticationSignOutHandler);
}
/// <summary>
/// Checks if the redirect URI is for a native client.
/// </summary>
internal static bool IsNativeClient(this AuthorizationRequest context)
{
return !context.RedirectUri.StartsWith("https", StringComparison.Ordinal)
&& !context.RedirectUri.StartsWith("http", StringComparison.Ordinal);
}
/// <summary>
/// Renders a loading page that is used to redirect back to the redirectUri.
/// </summary>
internal static IActionResult LoadingPage(this PageModel page, string? redirectUri)
{
page.HttpContext.Response.StatusCode = 200;
page.HttpContext.Response.Headers["Location"] = "";
return page.RedirectToPage("/Redirect/Index", new { RedirectUri = redirectUri });
}
}

@ -0,0 +1,19 @@
@page
@model IdentitySvc.Pages.ExternalLogin.Callback
@{
Layout = null;
}
<!DOCTYPE html>
<html>
<head>
<title></title>
</head>
<body>
<div>
</div>
</body>
</html>

@ -0,0 +1,203 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Security.Claims;
using Duende.IdentityServer;
using Duende.IdentityServer.Events;
using Duende.IdentityServer.Services;
using IdentityModel;
using IdentitySvc.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace IdentitySvc.Pages.ExternalLogin;
[AllowAnonymous]
[SecurityHeaders]
public class Callback : PageModel
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly IIdentityServerInteractionService _interaction;
private readonly ILogger<Callback> _logger;
private readonly IEventService _events;
public Callback(
IIdentityServerInteractionService interaction,
IEventService events,
ILogger<Callback> logger,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager)
{
_userManager = userManager;
_signInManager = signInManager;
_interaction = interaction;
_logger = logger;
_events = events;
}
public async Task<IActionResult> OnGet()
{
// read external identity from the temporary cookie
var result = await HttpContext.AuthenticateAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme);
if (result.Succeeded != true)
{
throw new InvalidOperationException($"External authentication error: { result.Failure }");
}
var externalUser = result.Principal ??
throw new InvalidOperationException("External authentication produced a null Principal");
if (_logger.IsEnabled(LogLevel.Debug))
{
var externalClaims = externalUser.Claims.Select(c => $"{c.Type}: {c.Value}");
_logger.ExternalClaims(externalClaims);
}
// lookup our user and external provider info
// try to determine the unique id of the external user (issued by the provider)
// the most common claim type for that are the sub claim and the NameIdentifier
// depending on the external provider, some other claim type might be used
var userIdClaim = externalUser.FindFirst(JwtClaimTypes.Subject) ??
externalUser.FindFirst(ClaimTypes.NameIdentifier) ??
throw new InvalidOperationException("Unknown userid");
var provider = result.Properties.Items["scheme"] ?? throw new InvalidOperationException("Null scheme in authentiation properties");
var providerUserId = userIdClaim.Value;
// find external user
var user = await _userManager.FindByLoginAsync(provider, providerUserId);
if (user == null)
{
// this might be where you might initiate a custom workflow for user registration
// in this sample we don't show how that would be done, as our sample implementation
// simply auto-provisions new external user
user = await AutoProvisionUserAsync(provider, providerUserId, externalUser.Claims);
}
// this allows us to collect any additional claims or properties
// for the specific protocols used and store them in the local auth cookie.
// this is typically used to store data needed for signout from those protocols.
var additionalLocalClaims = new List<Claim>();
var localSignInProps = new AuthenticationProperties();
CaptureExternalLoginContext(result, additionalLocalClaims, localSignInProps);
// issue authentication cookie for user
await _signInManager.SignInWithClaimsAsync(user, localSignInProps, additionalLocalClaims);
// delete temporary cookie used during external authentication
await HttpContext.SignOutAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme);
// retrieve return URL
var returnUrl = result.Properties.Items["returnUrl"] ?? "~/";
// check if external login is in the context of an OIDC request
var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
await _events.RaiseAsync(new UserLoginSuccessEvent(provider, providerUserId, user.Id, user.UserName, true, context?.Client.ClientId));
Telemetry.Metrics.UserLogin(context?.Client.ClientId, provider!);
if (context != null)
{
if (context.IsNativeClient())
{
// The client is native, so this change in how to
// return the response is for better UX for the end user.
return this.LoadingPage(returnUrl);
}
}
return Redirect(returnUrl);
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1851:Possible multiple enumerations of 'IEnumerable' collection", Justification = "<Pending>")]
private async Task<ApplicationUser> AutoProvisionUserAsync(string provider, string providerUserId, IEnumerable<Claim> claims)
{
var sub = Guid.NewGuid().ToString();
var user = new ApplicationUser
{
Id = sub,
UserName = sub, // don't need a username, since the user will be using an external provider to login
};
// email
var email = claims.FirstOrDefault(x => x.Type == JwtClaimTypes.Email)?.Value ??
claims.FirstOrDefault(x => x.Type == ClaimTypes.Email)?.Value;
if (email != null)
{
user.Email = email;
}
// create a list of claims that we want to transfer into our store
var filtered = new List<Claim>();
// user's display name
var name = claims.FirstOrDefault(x => x.Type == JwtClaimTypes.Name)?.Value ??
claims.FirstOrDefault(x => x.Type == ClaimTypes.Name)?.Value;
if (name != null)
{
filtered.Add(new Claim(JwtClaimTypes.Name, name));
}
else
{
var first = claims.FirstOrDefault(x => x.Type == JwtClaimTypes.GivenName)?.Value ??
claims.FirstOrDefault(x => x.Type == ClaimTypes.GivenName)?.Value;
var last = claims.FirstOrDefault(x => x.Type == JwtClaimTypes.FamilyName)?.Value ??
claims.FirstOrDefault(x => x.Type == ClaimTypes.Surname)?.Value;
if (first != null && last != null)
{
filtered.Add(new Claim(JwtClaimTypes.Name, first + " " + last));
}
else if (first != null)
{
filtered.Add(new Claim(JwtClaimTypes.Name, first));
}
else if (last != null)
{
filtered.Add(new Claim(JwtClaimTypes.Name, last));
}
}
var identityResult = await _userManager.CreateAsync(user);
if (!identityResult.Succeeded) throw new InvalidOperationException(identityResult.Errors.First().Description);
if (filtered.Count != 0)
{
identityResult = await _userManager.AddClaimsAsync(user, filtered);
if (!identityResult.Succeeded) throw new InvalidOperationException(identityResult.Errors.First().Description);
}
identityResult = await _userManager.AddLoginAsync(user, new UserLoginInfo(provider, providerUserId, provider));
if (!identityResult.Succeeded) throw new InvalidOperationException(identityResult.Errors.First().Description);
return user;
}
// if the external login is OIDC-based, there are certain things we need to preserve to make logout work
// this will be different for WS-Fed, SAML2p or other protocols
private static void CaptureExternalLoginContext(AuthenticateResult externalResult, List<Claim> localClaims, AuthenticationProperties localSignInProps)
{
ArgumentNullException.ThrowIfNull(externalResult.Principal, nameof(externalResult.Principal));
// capture the idp used to login, so the session knows where the user came from
localClaims.Add(new Claim(JwtClaimTypes.IdentityProvider, externalResult.Properties?.Items["scheme"] ?? "unknown identity provider"));
// if the external system sent a session id claim, copy it over
// so we can use it for single sign-out
var sid = externalResult.Principal.Claims.FirstOrDefault(x => x.Type == JwtClaimTypes.SessionId);
if (sid != null)
{
localClaims.Add(new Claim(JwtClaimTypes.SessionId, sid.Value));
}
// if the external provider issued an id_token, we'll keep it for signout
var idToken = externalResult.Properties?.GetTokenValue("id_token");
if (idToken != null)
{
localSignInProps.StoreTokens(new[] { new AuthenticationToken { Name = "id_token", Value = idToken } });
}
}
}

@ -0,0 +1,19 @@
@page
@model IdentitySvc.Pages.ExternalLogin.Challenge
@{
Layout = null;
}
<!DOCTYPE html>
<html>
<head>
<title></title>
</head>
<body>
<div>
</div>
</body>
</html>

@ -0,0 +1,48 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.IdentityServer.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace IdentitySvc.Pages.ExternalLogin;
[AllowAnonymous]
[SecurityHeaders]
public class Challenge : PageModel
{
private readonly IIdentityServerInteractionService _interactionService;
public Challenge(IIdentityServerInteractionService interactionService)
{
_interactionService = interactionService;
}
public IActionResult OnGet(string scheme, string? returnUrl)
{
if (string.IsNullOrEmpty(returnUrl)) returnUrl = "~/";
// validate returnUrl - either it is a valid OIDC URL or back to a local page
if (Url.IsLocalUrl(returnUrl) == false && _interactionService.IsValidReturnUrl(returnUrl) == false)
{
// user might have clicked on a malicious link - should be logged
throw new ArgumentException("invalid return URL");
}
// start challenge and roundtrip the return URL and scheme
var props = new AuthenticationProperties
{
RedirectUri = Url.Page("/externallogin/callback"),
Items =
{
{ "returnUrl", returnUrl },
{ "scheme", scheme },
}
};
return Challenge(props, scheme);
}
}

@ -0,0 +1,90 @@
@page
@model IdentitySvc.Pages.Grants.Index
@{
}
<div class="grants-page">
<div class="lead">
<h1>Client Application Permissions</h1>
<p>Below is the list of applications you have given permission to and the resources they have access to.</p>
</div>
@if (!Model.View.Grants.Any())
{
<div class="row">
<div class="col-sm-8">
<div class="alert alert-info">
You have not given access to any applications
</div>
</div>
</div>
}
else
{
foreach (var grant in Model.View.Grants)
{
<div class="card">
<div class="card-header">
<div class="row">
<div class="col-sm-8 card-title">
@if (grant.ClientLogoUrl != null)
{
<img src="@grant.ClientLogoUrl">
}
<strong>@grant.ClientName</strong>
</div>
<div class="col-sm-2">
<form asp-page="/Grants/Index">
<input type="hidden" name="clientId" value="@grant.ClientId">
<button class="btn btn-danger">Revoke Access</button>
</form>
</div>
</div>
</div>
<ul class="list-group list-group-flush">
@if (grant.Description != null)
{
<li class="list-group-item">
<label>Description:</label> @grant.Description
</li>
}
<li class="list-group-item">
<label>Created:</label> @grant.Created.ToString("yyyy-MM-dd")
</li>
@if (grant.Expires.HasValue)
{
<li class="list-group-item">
<label>Expires:</label> @grant.Expires.Value.ToString("yyyy-MM-dd")
</li>
}
@if (grant.IdentityGrantNames.Any())
{
<li class="list-group-item">
<label>Identity Grants</label>
<ul>
@foreach (var name in grant.IdentityGrantNames)
{
<li>@name</li>
}
</ul>
</li>
}
@if (grant.ApiGrantNames.Any())
{
<li class="list-group-item">
<label>API Grants</label>
<ul>
@foreach (var name in grant.ApiGrantNames)
{
<li>@name</li>
}
</ul>
</li>
}
</ul>
</div>
}
}
</div>

@ -0,0 +1,82 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.IdentityServer.Events;
using Duende.IdentityServer.Extensions;
using Duende.IdentityServer.Services;
using Duende.IdentityServer.Stores;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace IdentitySvc.Pages.Grants;
[SecurityHeaders]
[Authorize]
public class Index : PageModel
{
private readonly IIdentityServerInteractionService _interaction;
private readonly IClientStore _clients;
private readonly IResourceStore _resources;
private readonly IEventService _events;
public Index(IIdentityServerInteractionService interaction,
IClientStore clients,
IResourceStore resources,
IEventService events)
{
_interaction = interaction;
_clients = clients;
_resources = resources;
_events = events;
}
public ViewModel View { get; set; } = default!;
public async Task OnGet()
{
var grants = await _interaction.GetAllUserGrantsAsync();
var list = new List<GrantViewModel>();
foreach (var grant in grants)
{
var client = await _clients.FindClientByIdAsync(grant.ClientId);
if (client != null)
{
var resources = await _resources.FindResourcesByScopeAsync(grant.Scopes);
var item = new GrantViewModel()
{
ClientId = client.ClientId,
ClientName = client.ClientName ?? client.ClientId,
ClientLogoUrl = client.LogoUri,
ClientUrl = client.ClientUri,
Description = grant.Description,
Created = grant.CreationTime,
Expires = grant.Expiration,
IdentityGrantNames = resources.IdentityResources.Select(x => x.DisplayName ?? x.Name).ToArray(),
ApiGrantNames = resources.ApiScopes.Select(x => x.DisplayName ?? x.Name).ToArray()
};
list.Add(item);
}
}
View = new ViewModel
{
Grants = list
};
}
[BindProperty]
public string? ClientId { get; set; }
public async Task<IActionResult> OnPost()
{
await _interaction.RevokeUserConsentAsync(ClientId);
await _events.RaiseAsync(new GrantsRevokedEvent(User.GetSubjectId(), ClientId));
Telemetry.Metrics.GrantsRevoked(ClientId);
return RedirectToPage("/Grants/Index");
}
}

@ -0,0 +1,22 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace IdentitySvc.Pages.Grants;
public class ViewModel
{
public IEnumerable<GrantViewModel> Grants { get; set; } = Enumerable.Empty<GrantViewModel>();
}
public class GrantViewModel
{
public string? ClientId { get; set; }
public string? ClientName { get; set; }
public string? ClientUrl { get; set; }
public string? ClientLogoUrl { get; set; }
public string? Description { get; set; }
public DateTime Created { get; set; }
public DateTime? Expires { get; set; }
public IEnumerable<string> IdentityGrantNames { get; set; } = Enumerable.Empty<string>();
public IEnumerable<string> ApiGrantNames { get; set; } = Enumerable.Empty<string>();
}

@ -0,0 +1,35 @@
@page
@model IdentitySvc.Pages.Error.Index
<div class="error-page">
<div class="lead">
<h1>Error</h1>
</div>
<div class="row">
<div class="col-sm-6">
<div class="alert alert-danger">
Sorry, there was an error
@if (Model.View.Error != null)
{
<strong>
<em>
: @Model.View.Error.Error
</em>
</strong>
if (Model.View.Error.ErrorDescription != null)
{
<div>@Model.View.Error.ErrorDescription</div>
}
}
</div>
@if (Model?.View?.Error?.RequestId != null)
{
<div class="request-id">Request Id: @Model.View.Error.RequestId</div>
}
</div>
</div>
</div>

@ -0,0 +1,40 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.IdentityServer.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace IdentitySvc.Pages.Error;
[AllowAnonymous]
[SecurityHeaders]
public class Index : PageModel
{
private readonly IIdentityServerInteractionService _interaction;
private readonly IWebHostEnvironment _environment;
public ViewModel View { get; set; } = new();
public Index(IIdentityServerInteractionService interaction, IWebHostEnvironment environment)
{
_interaction = interaction;
_environment = environment;
}
public async Task OnGet(string? errorId)
{
// retrieve error details from identityserver
var message = await _interaction.GetErrorContextAsync(errorId);
if (message != null)
{
View.Error = message;
if (!_environment.IsDevelopment())
{
// only show in development
message.ErrorDescription = null;
}
}
}
}

@ -0,0 +1,20 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.IdentityServer.Models;
namespace IdentitySvc.Pages.Error;
public class ViewModel
{
public ViewModel()
{
}
public ViewModel(string error)
{
Error = new ErrorMessage { Error = error };
}
public ErrorMessage? Error { get; set; }
}

@ -0,0 +1,22 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
// This file is used by Code Analysis to maintain SuppressMessage
// attributes that are applied to this project.
// Project-level suppressions either have no target or are given
// a specific target and scoped to a namespace, type, member, etc.
using System.Diagnostics.CodeAnalysis;
// global/shared
[assembly: SuppressMessage("Design", "CA1054:URI-like parameters should not be strings", Justification = "Consistent with the IdentityServer APIs")]
[assembly: SuppressMessage("Design", "CA1056:URI-like properties should not be strings", Justification = "Consistent with the IdentityServer APIs")]
[assembly: SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task", Justification = "No need for ConfigureAwait in ASP.NET Core application code, as there is no SynchronizationContext.")]
// page specific
[assembly: SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "TestUsers are not designed to be extended", Scope = "member", Target = "~P:IdentitySvc.TestUsers.Users")]
[assembly: SuppressMessage("Design", "CA1034:Nested types should not be visible", Justification = "ExternalProvider is nested by design", Scope = "type", Target = "~T:IdentitySvc.Pages.Login.ViewModel.ExternalProvider")]
[assembly: SuppressMessage("Naming", "CA1716:Identifiers should not match keywords", Justification = "This namespace is just for organization, and won't be referenced elsewhere", Scope = "namespace", Target = "~N:IdentitySvc.Pages.Error")]
[assembly: SuppressMessage("Naming", "CA1724:Type names should not match namespaces", Justification = "Namespaces of pages are not likely to be used elsewhere, so there is little chance of confusion", Scope = "type", Target = "~T:IdentitySvc.Pages.Ciba.Consent")]
[assembly: SuppressMessage("Naming", "CA1724:Type names should not match namespaces", Justification = "Namespaces of pages are not likely to be used elsewhere, so there is little chance of confusion", Scope = "type", Target = "~T:IdentitySvc.Pages.Extensions")]
[assembly: SuppressMessage("Performance", "CA1805:Do not initialize unnecessarily", Justification = "This is for clarity and consistency with the surrounding code", Scope = "member", Target = "~F:IdentitySvc.Pages.Logout.LogoutOptions.AutomaticRedirectAfterSignOut")]

@ -0,0 +1,46 @@
@page
@model IdentitySvc.Pages.Home.Index
<div class="welcome-page">
<h1>
<img src="~/duende-logo.svg" class="logo">
Welcome to Duende IdentityServer
<small class="text-muted">(version @Model.Version)</small>
</h1>
<ul>
<li>
IdentityServer publishes a
<a href="~/.well-known/openid-configuration">discovery document</a>
where you can find metadata and links to all the endpoints, key material, etc.
</li>
<li>
Click <a href="~/diagnostics">here</a> to see the claims for your current session.
</li>
<li>
Click <a href="~/grants">here</a> to manage your stored grants.
</li>
<li>
Click <a href="~/serversidesessions">here</a> to view the server side sessions.
</li>
<li>
Click <a href="~/ciba/all">here</a> to view your pending CIBA login requests.
</li>
<li>
Here are links to the
<a href="https://github.com/duendesoftware/IdentityServer">source code repository</a>,
and <a href="https://github.com/duendesoftware/samples">ready to use samples</a>.
</li>
</ul>
@if(Model.License != null)
{
<h2>License</h2>
<dl>
<dt>Serial Number</dt>
<dd>@Model.License.SerialNumber</dd>
<dt>Expiration</dt>
<dd>@Model.License.Expiration!.Value.ToLongDateString()</dd>
</dl>
}
</div>

@ -0,0 +1,27 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.IdentityServer;
using System.Reflection;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace IdentitySvc.Pages.Home;
[AllowAnonymous]
public class Index : PageModel
{
public Index(IdentityServerLicense? license = null)
{
License = license;
}
public string Version
{
get => typeof(Duende.IdentityServer.Hosting.IdentityServerMiddleware).Assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion.Split('+').First()
?? "unavailable";
}
public IdentityServerLicense? License { get; }
}

@ -0,0 +1,87 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace IdentitySvc.Pages;
internal static class Log
{
private static readonly Action<ILogger, string?, Exception?> _invalidId = LoggerMessage.Define<string?>(
LogLevel.Error,
EventIds.InvalidId,
"Invalid id {Id}");
public static void InvalidId(this ILogger logger, string? id)
{
_invalidId(logger, id, null);
}
private static readonly Action<ILogger, string?, Exception?> _invalidBackchannelLoginId = LoggerMessage.Define<string?>(
LogLevel.Warning,
EventIds.InvalidBackchannelLoginId,
"Invalid backchannel login id {Id}");
public static void InvalidBackchannelLoginId(this ILogger logger, string? id)
{
_invalidBackchannelLoginId(logger, id, null);
}
private static Action<ILogger, IEnumerable<string>, Exception?> _externalClaims = LoggerMessage.Define<IEnumerable<string>>(
LogLevel.Debug,
EventIds.ExternalClaims,
"External claims: {Claims}");
public static void ExternalClaims(this ILogger logger, IEnumerable<string> claims)
{
_externalClaims(logger, claims, null);
}
private static Action<ILogger, string, Exception?> _noMatchingBackchannelLoginRequest = LoggerMessage.Define<string>(
LogLevel.Error,
EventIds.NoMatchingBackchannelLoginRequest,
"No backchannel login request matching id: {Id}");
public static void NoMatchingBackchannelLoginRequest(this ILogger logger, string id)
{
_noMatchingBackchannelLoginRequest(logger, id, null);
}
private static Action<ILogger, string, Exception?> _noConsentMatchingRequest = LoggerMessage.Define<string>(
LogLevel.Error,
EventIds.NoConsentMatchingRequest,
"No consent request matching request: {ReturnUrl}");
public static void NoConsentMatchingRequest(this ILogger logger, string returnUrl)
{
_noConsentMatchingRequest(logger, returnUrl, null);
}
}
internal static class EventIds
{
private const int UIEventsStart = 10000;
//////////////////////////////
// Consent
//////////////////////////////
private const int ConsentEventsStart = UIEventsStart + 1000;
public const int InvalidId = ConsentEventsStart + 0;
public const int NoConsentMatchingRequest = ConsentEventsStart + 1;
//////////////////////////////
// External Login
//////////////////////////////
private const int ExternalLoginEventsStart = UIEventsStart + 2000;
public const int ExternalClaims = ExternalLoginEventsStart + 0;
//////////////////////////////
// CIBA
//////////////////////////////
private const int CibaEventsStart = UIEventsStart + 3000;
public const int InvalidBackchannelLoginId = CibaEventsStart + 0;
public const int NoMatchingBackchannelLoginRequest = CibaEventsStart + 1;
}

@ -0,0 +1,14 @@
@page
@model IdentitySvc.Pages.Redirect.IndexModel
@{
}
<div class="redirect-page">
<div class="lead">
<h1>You are now being returned to the application</h1>
<p>Once complete, you may close this tab.</p>
</div>
</div>
<meta http-equiv="refresh" content="0;url=@Model.RedirectUri" data-url="@Model.RedirectUri">
<script src="~/js/signin-redirect.js"></script>

@ -0,0 +1,25 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace IdentitySvc.Pages.Redirect;
[AllowAnonymous]
public class IndexModel : PageModel
{
public string? RedirectUri { get; set; }
public IActionResult OnGet(string? redirectUri)
{
if (!Url.IsLocalUrl(redirectUri))
{
return RedirectToPage("/Home/Error/Index");
}
RedirectUri = redirectUri;
return Page();
}
}

@ -0,0 +1,56 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace IdentitySvc.Pages;
public sealed class SecurityHeadersAttribute : ActionFilterAttribute
{
public override void OnResultExecuting(ResultExecutingContext context)
{
ArgumentNullException.ThrowIfNull(context, nameof(context));
var result = context.Result;
if (result is PageResult)
{
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options
if (!context.HttpContext.Response.Headers.ContainsKey("X-Content-Type-Options"))
{
context.HttpContext.Response.Headers.Append("X-Content-Type-Options", "nosniff");
}
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options
if (!context.HttpContext.Response.Headers.ContainsKey("X-Frame-Options"))
{
context.HttpContext.Response.Headers.Append("X-Frame-Options", "DENY");
}
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
var csp = "default-src 'self'; object-src 'none'; frame-ancestors 'none'; sandbox allow-forms allow-same-origin allow-scripts; base-uri 'self';";
// also consider adding upgrade-insecure-requests once you have HTTPS in place for production
//csp += "upgrade-insecure-requests;";
// also an example if you need client images to be displayed from twitter
// csp += "img-src 'self' https://pbs.twimg.com;";
// once for standards compliant browsers
if (!context.HttpContext.Response.Headers.ContainsKey("Content-Security-Policy"))
{
context.HttpContext.Response.Headers.Append("Content-Security-Policy", csp);
}
// and once again for IE
if (!context.HttpContext.Response.Headers.ContainsKey("X-Content-Security-Policy"))
{
context.HttpContext.Response.Headers.Append("X-Content-Security-Policy", csp);
}
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy
var referrer_policy = "no-referrer";
if (!context.HttpContext.Response.Headers.ContainsKey("Referrer-Policy"))
{
context.HttpContext.Response.Headers.Append("Referrer-Policy", referrer_policy);
}
}
}
}

@ -0,0 +1,147 @@
@page
@model IdentitySvc.Pages.ServerSideSessions.IndexModel
<div class="users-page">
<div class="row">
<div class="col">
<div class="card">
<div class="card-header">
<h2>User Sessions</h2>
</div>
<div class="card-body">
@if (Model.UserSessions != null)
{
<div class="row">
<div class="col-1 text-center">
@if (Model.UserSessions.HasPrevResults)
{
<a class="btn btn-primary" asp-page="/ServerSideSessions/Index"
asp-route-prev="true"
asp-route-token="@Model.UserSessions.ResultsToken"
asp-route-DisplayNameFilter="@Model.DisplayNameFilter"
asp-route-SubjectIdFilter="@Model.SubjectIdFilter"
asp-route-SessionIdFilter="@Model.SessionIdFilter"
>Prev</a>
}
</div>
<div class="col">
<form class="form">
<div class="form-group row">
<label asp-for="@Model.DisplayNameFilter" class="col-2 col-form-label">Name:</label>
<input type="search" asp-for="@Model.DisplayNameFilter" class="col form-control" autofocus />
</div>
<div class="form-group row">
<label asp-for="@Model.SessionIdFilter" class="col-2 col-form-label">Session Id:</label>
<input type="search" asp-for="@Model.SessionIdFilter" class="col form-control" autofocus />
</div>
<div class="form-group row">
<label asp-for="@Model.SubjectIdFilter" class="col-2 col-form-label">Subject Id:</label>
<input type="search" asp-for="@Model.SubjectIdFilter" class="col form-control" autofocus />
</div>
<div class="form-group row justify-content-end">
<button type="submit" class="form-control btn btn-success col-1">Filter</button>
</div>
</form>
</div>
<div class="col-1 text-center">
@if (Model.UserSessions.HasNextResults)
{
<a class="btn btn-primary" asp-page="/ServerSideSessions/Index"
asp-route-token="@Model.UserSessions.ResultsToken"
asp-route-DisplayNameFilter="@Model.DisplayNameFilter"
asp-route-SubjectIdFilter="@Model.SubjectIdFilter"
asp-route-SessionIdFilter="@Model.SessionIdFilter"
>Next</a>
}
</div>
</div>
@if (Model.UserSessions.TotalCount.HasValue)
{
<div class="text-center">
@if (Model.UserSessions.CurrentPage.HasValue && Model.UserSessions.TotalPages.HasValue)
{
<text>
Total Results: @Model.UserSessions.TotalCount,
Page @Model.UserSessions.CurrentPage of @Model.UserSessions.TotalPages
</text>
}
else
{
<text>
Total Results: @Model.UserSessions.TotalCount
</text>
}
</div>
}
<br />
@if (Model.UserSessions.Results.Any())
{
<div>
<table class="table table-bordered table-striped table-sm">
<thead>
<tr>
<th>Subject Id</th>
<th>Session Id</th>
<th>Display Name</th>
<th>Created</th>
<th>Expires</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var session in Model.UserSessions.Results)
{
<tr>
<td>@session.SubjectId</td>
<td>@session.SessionId</td>
<td>@session.DisplayName</td>
<td>@session.Created</td>
<td>@session.Expires</td>
<td>
<form method="post">
<input type="hidden" name="SessionId" value="@session.SessionId" />
<button type="submit" class="btn btn-danger">Delete</button>
</form>
</td>
</tr>
<tr><td colspan="6">
<strong>Clients:</strong>
@if (session.ClientIds?.Any() == true)
{
@(session.ClientIds.Aggregate((x, y) => $"{x}, {y}"))
}
else
{
@("None")
}
</td></tr>
}
</tbody>
</table>
</div>
}
else
{
<div class="text-center">No User Sessions</div>
}
}
else
{
<div class="row">
<div class="col">
You do not have server-side sessions enabled.
To do so, use <i>AddServerSideSessions</i> on your IdentityServer configuration.
See the <a href="https://docs.duendesoftware.com/identityserver/v6/ui/server_side_sessions">documentation</a> for more information.
</div>
</div>
}
</div>
</div>
</div>
</div>
</div>

@ -0,0 +1,67 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Services;
using Duende.IdentityServer.Stores;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace IdentitySvc.Pages.ServerSideSessions
{
public class IndexModel : PageModel
{
private readonly ISessionManagementService? _sessionManagementService;
public IndexModel(ISessionManagementService? sessionManagementService = null)
{
_sessionManagementService = sessionManagementService;
}
public QueryResult<UserSession>? UserSessions { get; set; }
[BindProperty(SupportsGet = true)]
public string? DisplayNameFilter { get; set; }
[BindProperty(SupportsGet = true)]
public string? SessionIdFilter { get; set; }
[BindProperty(SupportsGet = true)]
public string? SubjectIdFilter { get; set; }
[BindProperty(SupportsGet = true)]
public string? Token { get; set; }
[BindProperty(SupportsGet = true)]
public string? Prev { get; set; }
public async Task OnGet()
{
if (_sessionManagementService != null)
{
UserSessions = await _sessionManagementService.QuerySessionsAsync(new SessionQuery
{
ResultsToken = Token,
RequestPriorResults = Prev == "true",
DisplayName = DisplayNameFilter,
SessionId = SessionIdFilter,
SubjectId = SubjectIdFilter
});
}
}
[BindProperty]
public string? SessionId { get; set; }
public async Task<IActionResult> OnPost()
{
ArgumentNullException.ThrowIfNull(_sessionManagementService);
await _sessionManagementService.RemoveSessionsAsync(new RemoveSessionsContext {
SessionId = SessionId,
});
return RedirectToPage("/ServerSideSessions/Index", new { Token, DisplayNameFilter, SessionIdFilter, SubjectIdFilter, Prev });
}
}
}

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no" />
<title>Duende IdentityServer</title>
<link rel="icon" type="image/x-icon" href="~/favicon.ico" />
<link rel="shortcut icon" type="image/x-icon" href="~/favicon.ico" />
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/lib/bootstrap4-glyphicons/css/bootstrap-glyphicons.min.css" />
<link rel="stylesheet" href="~/css/site.css" />
</head>
<body>
<partial name="_Nav" />
<div class="container body-container">
@RenderBody()
</div>
<script src="~/lib/jquery/dist/jquery.slim.min.js"></script>
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
@RenderSection("scripts", required: false)
</body>
</html>

@ -0,0 +1,33 @@
@using Duende.IdentityServer.Extensions
@{
#nullable enable
string? name = null;
if (!true.Equals(ViewData["signed-out"]))
{
name = Context.User?.GetDisplayName();
}
}
<div class="nav-page">
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<a href="~/" class="navbar-brand">
<img src="~/duende-logo.svg" class="icon-banner">
Optifit Identity SSO
</a>
@if (!string.IsNullOrWhiteSpace(name))
{
<ul class="navbar-nav mr-auto">
<li class="nav-item dropdown">
<a href="#" class="nav-link dropdown-toggle" data-toggle="dropdown">@name <b class="caret"></b></a>
<div class="dropdown-menu">
<a class="dropdown-item" asp-page="/Account/Logout/Index">Logout</a>
</div>
</li>
</ul>
}
</nav>
</div>

@ -0,0 +1,7 @@
@if (ViewContext.ModelState.IsValid == false)
{
<div class="alert alert-danger">
<strong>Error</strong>
<div asp-validation-summary="All" class="danger"></div>
</div>
}

@ -0,0 +1,142 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
using System.Diagnostics.Metrics;
namespace IdentitySvc.Pages;
#pragma warning disable CA1034 // Nested types should not be visible
#pragma warning disable CA1724 // Type names should not match namespaces
/// <summary>
/// Telemetry helpers for the UI
/// </summary>
public static class Telemetry
{
private static readonly string ServiceVersion = typeof(Telemetry).Assembly.GetName().Version!.ToString();
/// <summary>
/// Service name for telemetry.
/// </summary>
public static readonly string ServiceName = typeof(Telemetry).Assembly.GetName().Name!;
/// <summary>
/// Metrics configuration
/// </summary>
public static class Metrics
{
#pragma warning disable 1591
/// <summary>
/// Name of Counters
/// </summary>
public static class Counters
{
public const string Consent = "tokenservice.consent";
public const string GrantsRevoked = "tokenservice.grants_revoked";
public const string UserLogin = "tokenservice.user_login";
public const string UserLogout = "tokenservice.user_logout";
}
/// <summary>
/// Name of tags
/// </summary>
public static class Tags
{
public const string Client = "client";
public const string Error = "error";
public const string Idp = "idp";
public const string Remember = "remember";
public const string Scope = "scope";
public const string Consent = "consent";
}
/// <summary>
/// Values of tags
/// </summary>
public static class TagValues
{
public const string Granted = "granted";
public const string Denied = "denied";
}
#pragma warning restore 1591
/// <summary>
/// Meter for the IdentityServer host project
/// </summary>
private static readonly Meter Meter = new Meter(ServiceName, ServiceVersion);
private static Counter<long> ConsentCounter = Meter.CreateCounter<long>(Counters.Consent);
/// <summary>
/// Helper method to increase <see cref="Counters.Consent"/> counter. The scopes
/// are expanded and called one by one to not cause a combinatory explosion of scopes.
/// </summary>
/// <param name="clientId">Client id</param>
/// <param name="scopes">Scope names. Each element is added on it's own to the counter</param>
public static void ConsentGranted(string clientId, IEnumerable<string> scopes, bool remember)
{
ArgumentNullException.ThrowIfNull(scopes);
foreach (var scope in scopes)
{
ConsentCounter.Add(1,
new(Tags.Client, clientId),
new(Tags.Scope, scope),
new(Tags.Remember, remember),
new(Tags.Consent, TagValues.Granted));
}
}
/// <summary>
/// Helper method to increase <see cref="Counters.ConsentDenied"/> counter. The scopes
/// are expanded and called one by one to not cause a combinatory explosion of scopes.
/// </summary>
/// <param name="clientId">Client id</param>
/// <param name="scopes">Scope names. Each element is added on it's own to the counter</param>
public static void ConsentDenied(string clientId, IEnumerable<string> scopes)
{
ArgumentNullException.ThrowIfNull(scopes);
foreach (var scope in scopes)
{
ConsentCounter.Add(1, new(Tags.Client, clientId), new(Tags.Scope, scope), new(Tags.Consent, TagValues.Denied));
}
}
private static Counter<long> GrantsRevokedCounter = Meter.CreateCounter<long>(Counters.GrantsRevoked);
/// <summary>
/// Helper method to increase the <see cref="Counters.GrantsRevoked"/> counter.
/// </summary>
/// <param name="clientId">Client id to revoke for, or null for all.</param>
public static void GrantsRevoked(string? clientId)
=> GrantsRevokedCounter.Add(1, tag: new(Tags.Client, clientId));
private static Counter<long> UserLoginCounter = Meter.CreateCounter<long>(Counters.UserLogin);
/// <summary>
/// Helper method to increase <see cref="Counters.UserLogin"/> counter.
/// </summary>
/// <param name="clientId">Client Id, if available</param>
public static void UserLogin(string? clientId, string idp)
=> UserLoginCounter.Add(1, new(Tags.Client, clientId), new(Tags.Idp, idp));
/// <summary>
/// Helper method to increase <see cref="Counters.UserLogin" counter on failure.
/// </summary>
/// <param name="clientId">Client Id, if available</param>
/// <param name="error">Error message</param>
public static void UserLoginFailure(string? clientId, string idp, string error)
=> UserLoginCounter.Add(1, new(Tags.Client, clientId), new(Tags.Idp, idp), new(Tags.Error, error));
private static Counter<long> UserLogoutCounter = Meter.CreateCounter<long>(Counters.UserLogout);
/// <summary>
/// Helper method to increase the <see cref="Counters.UserLogout"/> counter.
/// </summary>
/// <param name="idp">Idp/authentication scheme for external authentication, or "local" for built in.</param>
public static void UserLogout(string? idp)
=> UserLogoutCounter.Add(1, tag: new(Tags.Idp, idp));
}
}

@ -0,0 +1,2 @@
@using IdentitySvc.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}

@ -0,0 +1,40 @@
using IdentitySvc;
using Serilog;
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.CreateBootstrapLogger();
Log.Information("Starting up");
try
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Host.UseSerilog((ctx, lc) => lc
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}{NewLine}")
.Enrich.FromLogContext()
.ReadFrom.Configuration(ctx.Configuration));
var app = builder
.ConfigureServices()
.ConfigurePipeline();
// this seeding is only for the template to bootstrap the DB and users.
// in production you will likely want a different approach.
SeedData.EnsureSeedData(app);
app.MapControllers();
app.Run();
}
catch (Exception ex) when (ex is not HostAbortedException)
{
Log.Fatal(ex, "Unhandled exception");
}
finally
{
Log.Information("Shut down complete");
Log.CloseAndFlush();
}

@ -0,0 +1,12 @@
{
"profiles": {
"SelfHost": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "http://localhost:7003"
}
}
}

@ -0,0 +1,81 @@
using System.Security.Claims;
using IdentityModel;
using IdentitySvc.Data;
using IdentitySvc.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Serilog;
namespace IdentitySvc;
public class SeedData
{
public static void EnsureSeedData(WebApplication app)
{
using var scope = app.Services.GetRequiredService<IServiceScopeFactory>().CreateScope();
var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
context.Database.Migrate();
var userMgr = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
if (userMgr.Users.Any()) return;
var alice = userMgr.FindByNameAsync("alice").Result;
if (alice == null)
{
alice = new ApplicationUser
{
UserName = "alice",
Email = "AliceSmith@email.com",
EmailConfirmed = true,
};
var result = userMgr.CreateAsync(alice, "Pass123$").Result;
if (!result.Succeeded)
{
throw new Exception(result.Errors.First().Description);
}
result = userMgr.AddClaimsAsync(alice, new Claim[]{
new Claim(JwtClaimTypes.Name, "Alice Smith"),
}).Result;
if (!result.Succeeded)
{
throw new Exception(result.Errors.First().Description);
}
Log.Debug("alice created");
}
else
{
Log.Debug("alice already exists");
}
var bob = userMgr.FindByNameAsync("bob").Result;
if (bob == null)
{
bob = new ApplicationUser
{
UserName = "bob",
Email = "BobSmith@email.com",
EmailConfirmed = true
};
var result = userMgr.CreateAsync(bob, "Pass123$").Result;
if (!result.Succeeded)
{
throw new Exception(result.Errors.First().Description);
}
result = userMgr.AddClaimsAsync(bob, new Claim[]{
new Claim(JwtClaimTypes.Name, "Bob Smith"),
}).Result;
if (!result.Succeeded)
{
throw new Exception(result.Errors.First().Description);
}
Log.Debug("bob created");
}
else
{
Log.Debug("bob already exists");
}
}
}

@ -0,0 +1,37 @@
using System.Security.Claims;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Services;
using IdentityModel;
using Microsoft.AspNetCore.Identity;
using IdentitySvc.Models;
namespace IdentitySvc.Services;
public class CustomProfileService : IProfileService
{
private readonly UserManager<ApplicationUser> _userManager;
public CustomProfileService(UserManager<ApplicationUser> userManager)
{
_userManager = userManager;
}
public async Task GetProfileDataAsync(ProfileDataRequestContext context)
{
var user = await _userManager.GetUserAsync(context.Subject);
var existingClaims = await _userManager.GetClaimsAsync(user);
var claims = new List<Claim>
{
new Claim("username", user.UserName),
};
context.IssuedClaims.AddRange(claims);
context.IssuedClaims.Add(existingClaims.FirstOrDefault(x => x.Type == JwtClaimTypes.Name));
}
public Task IsActiveAsync(IsActiveContext context)
{
return Task.CompletedTask;
}
}

@ -0,0 +1,3 @@
rmdir /S /Q "Data/Migrations"
dotnet ef migrations add Users -c ApplicationDbContext -o Data/Migrations

@ -0,0 +1,5 @@
#!/bin/bash
rm -rf "Data/Migrations"
dotnet ef migrations add Users -c ApplicationDbContext -o Data/Migrations

@ -0,0 +1 @@
{"Version":1,"Id":"77EB8A5360F8A1CEBDE8A5C6954780A5","Created":"2025-05-25T15:12:00.8996178Z","Algorithm":"RS256","IsX509Certificate":false,"Data":"CfDJ8BIJalz9mTlNqljDUzwnDk0ARrMXC6Z0QHpR_RqrdI-s1X8AxnD86UJmmJrnZRBXC0-q1fGrU2GMD1bEaR0_EnEw4GU5_fX9B1EVWs5OgQNXazOxkkCj5rnCcJmu779FXuoKbCn95KHCljQFxcAClWlRL4-7ufG5YYn3ka_3He3cHZDEt8Kh2inMFZq2jWjoxi3F668ApZQty2RmEDulv512LYH0NlKXSYgGlb6GtAHzngGjiUTIjwhKtcZ8BhgMzurm-9AZ4zLOcnnrrkA4yPgyL9omiHrkrjgbttmq107N_RsIhV9xfe5g3NqckIhRvWCPbqfzvmtiKWrkgzQJSFr73I5bYH_-9Lf10k7Jd-XMrAuf2Yts8WUNxUVw9Bv_0uSOAgMdAH0gExBKw2WxXBzc71gFUly4_THC8Od5J6CoyQIGdsSJVUskpc5z4QkkUOzoO1BoLsNL8OpkjHpuGMoQDTlC4khHqD37tJPj1cHUt5uwOrJnInmzXXSLK3mTQOtuNmbDSiNO_UJ5DnPDzvhNoPJjiTeuRWGhnd13OiC8Ehk_EIfxmSkw3Xs5v54-97MAL71DD0QYurWc2lnXjqX4_7Kqpx0kmJpTibBHXuykVBa32lMKLroo6vSAkaKPeEFNoMZymIH5oABMVHApUs2jw_TheVwuJ0pvBhFwr4wfCa5x6IDmfuKYsglkA6JZMirIDHMvBuR2OcUuW22GOt6gzMLq2LoLcBHS1pfCip4Awyl4GjW8Rs-_syjlU0Pw_PR45tUTC0Zeq-_JHWSEuhPLDmcojXN9nyPu-Rx-NlMrabK4XG8xNDpMFLTm-3XpqPMHIHNYutm3gbx9oSsEVc98hrMC1GNiLvoM8UWUxAUjlgowy3O7-nfLQRZOPnA8mo6oADjK8HNKBMFuiyiRHYDs5Xb1x4n6_EC0YdV5qutFHnyu3DTsw0K9Z_l_HyryUdIZk6jjrSvLVTlyWNigjVQAgHqu4rGQDLKB1U7o9M8cz5cMLowgUvSytvbkG9D_cmVz6LgUI4K_Nw_mNtn2Zh38NAwF-8tlY4n8QM0c5nfI12Vw1bHv-CvjRDxDga3s-0wpFXhN8ZCXlLIlbIlGZQearIKkgoTy7ssL2Ki0PS7U-8daHlZpoD7EuGL8DaUK33dgS-xfGeiKXvZtTx8ryt_6uZI8kqvzP61kUgQ_PUihFY2U-n4tiSBQ3MW9uzPBWp1w6mLx2yhl_R7OiITUYtLXQieap2psIFdbfFW4MXNgWiyzw0otXdyJHiP56v7_pwBsdz8fXsxmOU9kaec86rJzBiEtfvkVNTPor3f37ctAi9FcsWVHqgcRbebsRSeNZBFas6Cd_xPxQkIEszdB7MAbIJOvBmVfijwpv3n2rplwef_aBLe7wbnmc2nPfDjW5L-QzYaAl7Jm-sDQ9GjVMvVQgrAnAhvz7gTIRbcCq5LN7yzy6dJfQPbqj_ejGImPJZuHGHhH2iad6ppvdrnIcpYVovqXO-ORiMOnNCTzzpRkDNA9cUP-KsxFc7r3pGDViGLB7hMzylcCa1-gRkAfYmcGfxu4s3ePpVk2P-0Vn0SScHItoUJkI6yaIJ6wzmgxxE3Gpx6zxqr9nn5I0pFzKaCktB7ppT0GqFxkBf88Zx1jpo6XRQHGs5rTsoAsjcaOCzqSZy5t9jI_vh8z53A29t5QkIKnROkpHywN5zQ8x2pnpHTk9aXp2geNjDFygVhNccYzVLjXSdMJmmcSG51-lGdbX9dSC6ND7EEXRBWyacOvQKYhRGKXZF8Edn1Hp40JIW3-ZeCF_kJF9VSP_-STftlPy7NGMIYvPre0L9NU2SNVBgBTwwCkaufHGyMufYgwMwJ8jZIUHErRennTW5vYDiagjDq9h6YOg0OLgHR4xwzydrZyJBMm1ICwsBcffgKfWYFsV1XuXtGDVZ8C3b0PtT6jb6D9ixSgxWEIGERuk2cJCQooyvtPSqyYIBS-OBqwQicISfroIFtUEYzaRZQib6BRI-VpvUh1_8FSdFSoje0Je3_DFrd1N-KBVfzOy7hdfZoNpipKeq8Xrmcy7B5yQB3jG0qiKHPvk-VstzGZvf1rzqIxHmaWvNXbCmh8oRLJji3Y3LQEnxOr1lgas5jJzjplDLX7cDo08KgIX4R0yKHp34Xa_J7L8iuGcuoCIUyarxPus-ixmIA1non0Hm-6EOmYpT7h_zKmfE7H4V3i2ys4q0sOdB2y3sOUjzhHGXS8APmwnWzZX0BgStm6MrwTY3TNSC5SGegxdcLxKYpc695_5G6zxA2QRYP9_aU8ZyuRryupaB0gZUXCo9Qmzw3y2RJneteMd1SwX7X-q0W1yoV0w-C19Rd1NWuGUapKSjRYkoF4X_AJb5fAQ6XbBwgxcnlSTdJCshdphnH59mJCCR-DFaSHHpt2c7Fc9NdCN7BqzsnDCeOlnVjiVvFVbtd4mBhSk8715ul8lOpikgt9bzZi","DataProtected":true}

@ -0,0 +1,39 @@
.welcome-page .logo {
width: 64px;
}
.icon-banner {
width: 32px;
}
.body-container {
margin-top: 60px;
padding-bottom: 40px;
}
.welcome-page li {
list-style: none;
padding: 4px;
}
.logged-out-page iframe {
display: none;
width: 0;
height: 0;
}
.grants-page .card {
margin-top: 20px;
border-bottom: 1px solid lightgray;
}
.grants-page .card .card-title {
font-size: 120%;
font-weight: bold;
}
.grants-page .card .card-title img {
width: 100px;
height: 100px;
}
.grants-page .card label {
font-weight: bold;
}

@ -0,0 +1 @@
.welcome-page .logo{width:64px;}.icon-banner{width:32px;}.body-container{margin-top:60px;padding-bottom:40px;}.welcome-page li{list-style:none;padding:4px;}.logged-out-page iframe{display:none;width:0;height:0;}.grants-page .card{margin-top:20px;border-bottom:1px solid #d3d3d3;}.grants-page .card .card-title{font-size:120%;font-weight:bold;}.grants-page .card .card-title img{width:100px;height:100px;}.grants-page .card label{font-weight:bold;}

@ -0,0 +1,50 @@
.welcome-page {
.logo {
width: 64px;
}
}
.icon-banner {
width: 32px;
}
.body-container {
margin-top: 60px;
padding-bottom: 40px;
}
.welcome-page {
li {
list-style: none;
padding: 4px;
}
}
.logged-out-page {
iframe {
display: none;
width: 0;
height: 0;
}
}
.grants-page {
.card {
margin-top: 20px;
border-bottom: 1px solid lightgray;
.card-title {
img {
width: 100px;
height: 100px;
}
font-size: 120%;
font-weight: bold;
}
label {
font-weight: bold;
}
}
}

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100.23 111.82"><defs><style>.cls-1{fill:#633e9e;}.cls-2{fill:#43257c;}.cls-3{fill:#8361c5;}.cls-4{fill:#764fc1;}</style></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><rect class="cls-1" x="64.69" y="-3.71" width="22.24" height="73.86" rx="11.12" transform="translate(28 -34.34) rotate(31.05)"/><rect class="cls-2" x="63.78" y="41.2" width="22.24" height="73.86" rx="11.12" transform="translate(-28.46 46.02) rotate(-28.95)"/><rect class="cls-3" x="25.81" y="18.62" width="22.24" height="73.86" rx="11.12" transform="translate(-18.62 92.48) rotate(-90)"/><path class="cls-4" d="M75.23,55.55h0A11.19,11.19,0,0,0,64.05,44.43h-8L53,49.6a12.21,12.21,0,0,0-.79,1.63l-.15.36,0,.12a11,11,0,0,0,.69,9.21l3.19,5.75h8.19A11.18,11.18,0,0,0,75.23,55.55Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 845 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save