diff --git a/_source/_assets/img/avatar-ram-gandhi.jpg b/_source/_assets/img/avatar-ram-gandhi.jpg new file mode 100644 index 000000000..183fe2cf7 Binary files /dev/null and b/_source/_assets/img/avatar-ram-gandhi.jpg differ diff --git a/_source/_assets/img/blog/net-scim/import.jpeg b/_source/_assets/img/blog/net-scim/import.jpeg new file mode 100644 index 000000000..607b78b55 Binary files /dev/null and b/_source/_assets/img/blog/net-scim/import.jpeg differ diff --git a/_source/_assets/img/blog/net-scim/ngrok.jpeg b/_source/_assets/img/blog/net-scim/ngrok.jpeg new file mode 100644 index 000000000..ce4d9e387 Binary files /dev/null and b/_source/_assets/img/blog/net-scim/ngrok.jpeg differ diff --git a/_source/_assets/img/blog/net-scim/provisioning.jpeg b/_source/_assets/img/blog/net-scim/provisioning.jpeg new file mode 100644 index 000000000..6dba1f025 Binary files /dev/null and b/_source/_assets/img/blog/net-scim/provisioning.jpeg differ diff --git a/_source/_assets/img/blog/net-scim/social.jpg b/_source/_assets/img/blog/net-scim/social.jpg new file mode 100644 index 000000000..380d81911 Binary files /dev/null and b/_source/_assets/img/blog/net-scim/social.jpg differ diff --git a/_source/_data/authors.yml b/_source/_data/authors.yml index 851662d19..7cc09fdae 100644 --- a/_source/_data/authors.yml +++ b/_source/_data/authors.yml @@ -929,3 +929,9 @@ michael-palermo: web: https://about.me/palermo4 bio: Michael is the Manager of Developer Advocacy at Okta. He has been advocating developer technologies for over 25 years. Michael is a published author of technical books as well as online courses with Pluralsight. Previously, Michael evangelized "smart home" with the Amazon Alexa team, taught developers location data with HERE Technologies, and championed HTML5 while at Microsoft. +ram-gandhi: + full_name: Ram Gandhi + display_name: Ram Gandhi + avatar: avatar-ram-gandhi.jpg + github: https://github.com/ramgandhi-okta + bio: Ram Gandhi is a Developer Support Solutions Architect at Okta, a full-stack developer, DevOps expert, and a problem solver who is excited by learning new things. He has over 13 years of experience developing software applications across various industries and securing them using industry best practices. He loves to work in cross-platform development and Kubernetes. diff --git a/_source/_posts/2023-06-21-net-maui-authentication.md b/_source/_posts/2023-06-21-net-maui-authentication.md index 40761187c..376a0a90c 100644 --- a/_source/_posts/2023-06-21-net-maui-authentication.md +++ b/_source/_posts/2023-06-21-net-maui-authentication.md @@ -5,7 +5,7 @@ author: laura-rodriguez by: contractor communities: [mobile, .net] description: "Add authentication to your MAUI applications and keep your .NET mobile apps secure!" -tags: [net, mobile] +tags: [dotnet, mobile] tweets: - "" - "" diff --git a/_source/_posts/2024-02-29-net-scim.md b/_source/_posts/2024-02-29-net-scim.md new file mode 100644 index 000000000..4bd89a428 --- /dev/null +++ b/_source/_posts/2024-02-29-net-scim.md @@ -0,0 +1,1590 @@ +--- +layout: blog_post +title: "How to Manage User Lifecycle with .NET and SCIM" +author: ram-gandhi +by: contractor +communities: [.net] +description: "Manage your user's lifecycles using .NET, SCIM, and an Identity Provider such as Okta." +tags: [scim,dotnet] +tweets: +- "" +- "" +- "" +image: blog/net-scim/social.jpg +type: awareness +github: https://github.com/oktadev/okta-net-scim-example +--- + +Several challenges exist in provisioning users for a growing company across systems and applications. It can be time-consuming, error-prone, challenging to sync users across domains, and potentially a security risk to keep track of existing profiles. Fortunately, a protocol called SCIM (System for Cross-domain Identity Management) standardizes user information across systems, making it possible to sync users. Also, combined with a SCIM-compliant Identity Provider (IdP), it securely automates common user lifecycle operations. +In this tutorial, I'll walk you through building a .NET SCIM server with the CRUD (Create, Read, Update, Delete) Restful API endpoints outlined in the SCIM protocol. We'll then test the server using Swagger User Interface before integrating it with Okta, a SCIM compliant IdP. + +## Use SCIM to manage users +[SCIM](https://datatracker.ietf.org/doc/html/rfc7644) is an HTTP-based protocol defining standard endpoints and schemas to overcome complexity when implementing user lifecycle management operations across different systems. + +## Components of a SCIM integration +There are two primary components for a SCIM integration. The first is the *SCIM server*, which is a server that has implemented the endpoints as described in the SCIM spec — for example, User endpoints, Group endpoints, Schemas endpoints, etc. The applications team typically develops these endpoints, which leaves the IdP to manage the user profiles. This blog will detail creating a sample SCIM server using .NET. The other component is the *SCIM client*, which makes SCIM-compliant HTTP calls to a SCIM server to exchange user profile information. Okta (the IdP), in our use case, will act as a SCIM client and call the .NET SCIM server we'll build using this tutorial. + +## Build a .NET SCIM server + +**Prerequisites** +- dotnet SDK (I used [dotnet 8](https://dotnet.microsoft.com/en-us/download/dotnet/8.0) in this demo) +- Code Editor (I used Visual Studio Code) +- [Okta CLI](https://cli.okta.com/) + +{% include toc.md %} + +### Create a .NET project +Create a directory called `okta-scim-server-dotnet`. Navigate to the directory in your terminal, then run the following commands: +``` +dotnet new webapi (Creates a new API project) +dotnet dev-certs https --trust (Trust the self-signed TLS certs) +``` + +### Test the .NET project using Swagger UI +- Run the project using the command `dotnet watch --launch-profile https` +- At this point using the *https://localhost:7094/swagger/index.html* you will be able to see the Swagger UI. Typically, a browser tab will automatically open; if not, manually check for the URL to the Swagger UI under `Properties/launchSettings.json` and copy/paste this URL onto the browser: + +>**Note**: You may get different ports so be sure to double-check the URLs in `Properties/launchSettings.json`. + +### Set up Okta as your identity provider (IdP) for authentication and authorization +{% include setup/cli.md type="spa" loginRedirectUri="http://localhost:7094/swagger/oauth2-redirect.html" logoutRedirectUri="http://localhost:7094" %} + +Take note of the `Issuer` and the `Client ID`. You'll need those values for your authentication configuration coming up soon. + +### Create the database models using Entity Framework +This sample uses `EntityFrameworkCore` as the Object Relational Mapper (ORM) and `Sqlite` as the database. Let's develop the database models! + +Add the required dependencies by running the following commands + +```shell +dotnet tool install --global dotnet-ef +dotnet add package Microsoft.EntityFrameworkCore.Tools +dotnet add package Microsoft.EntityFrameworkCore.Design +dotnet add package Microsoft.EntityFrameworkCore.Sqlite +``` + +Create a `DataModels.cs` file and add the required model classes for `User` and child object `Email`. +```c# +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace okta_scim_server_dotnet; + +public partial class User +{ + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int Id { get; set; } + public string? ExternalId { get; set; } + + public string UserName { get; set; } + public string FirstName { get; set; } + public string LastName { get; set; } + public string? MiddleName { get; set; } + public string DisplayName { get; set; } + public bool Active { get; set; } + + public virtual ICollection? Emails { get; set; } +} + +[PrimaryKey(nameof(Value), nameof(UserId))] +public class Email +{ + public string Type { get; set; } + public string Value { get; set; } + public bool Primary { get; set; } + + public int UserId { get; set; } + public virtual User User { get; set; } +} +``` +Add DB context for each entity framework in the `DataModels.cs` file, (i.e., to the two models we created above). We'll also add some seed data using the method `OnModelCreating`, which will be useful for testing. In this sample, we will create support for the User resource type. However, if you want to expand on this, you can use similar concepts to maintain endpoints for other resource types, such as Group. Paste the following code just below the `Email` class: +```c# +public partial class ScimDbContext : DbContext +{ + public ScimDbContext(){} + public ScimDbContext(DbContextOptions options) : base(options) { } + + public virtual DbSet Users { get; set; } + public virtual DbSet Emails { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasIndex(u => u.UserName).IsUnique(); + modelBuilder.Entity().HasData(new List { + new User { Id = 1, FirstName = "Micky", LastName = "Daldo", DisplayName = "Micky Daldo", UserName = "mdaldo@fake.domain", Active = true }, + new User { Id = 2, FirstName = "Dan", LastName = "Slem", DisplayName = "Dan Slem", UserName = "dslem@fake.domain", Active = true }, + new User { Id = 3, FirstName = "Sarika", LastName = "Mahesh", DisplayName = "Sarika Mahesh", UserName = "smahesh@fake.domain", Active = true } + }); + modelBuilder.Entity().HasData(new List { + new Email { Type = "work", Value="mdaldo@fake.domain", Primary = true, UserId = 1 }, + new Email { Type = "personal", Value="mdaldo@personal.domain", Primary = false, UserId = 1 }, + new Email { Type = "work", Value="dslem@fake.domain", Primary = true, UserId = 2 }, + new Email { Type = "work", Value="smahesh@fake.domain", Primary = true, UserId = 3 } + }); + base.OnModelCreating(modelBuilder); + } +} +``` + +The `DataModels.cs` file should look like this: + +```c# +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace okta_scim_server_dotnet; + +public partial class User +{ + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int Id { get; set; } + public string? ExternalId { get; set; } + + public string UserName { get; set; } + public string FirstName { get; set; } + public string LastName { get; set; } + public string? MiddleName { get; set; } + public string DisplayName { get; set; } + public bool Active { get; set; } + + public virtual ICollection? Emails { get; set; } +} + +[PrimaryKey(nameof(Value), nameof(UserId))] +public class Email +{ + public string Type { get; set; } + public string Value { get; set; } + public bool Primary { get; set; } + + public int UserId { get; set; } + public virtual User User { get; set; } +} + +public partial class ScimDbContext : DbContext +{ + public ScimDbContext(){} + public ScimDbContext(DbContextOptions options) : base(options) { } + + public virtual DbSet Users { get; set; } + public virtual DbSet Emails { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasIndex(u => u.UserName).IsUnique(); + modelBuilder.Entity().HasData(new List { + new User { Id = 1, FirstName = "Micky", LastName = "Daldo", DisplayName = "Micky Daldo", UserName = "mdaldo@fake.domain", Active = true }, + new User { Id = 2, FirstName = "Dan", LastName = "Slem", DisplayName = "Dan Slem", UserName = "dslem@fake.domain", Active = true }, + new User { Id = 3, FirstName = "Sarika", LastName = "Mahesh", DisplayName = "Sarika Mahesh", UserName = "smahesh@fake.domain", Active = true } + }); + modelBuilder.Entity().HasData(new List { + new Email { Type = "work", Value="mdaldo@fake.domain", Primary = true, UserId = 1 }, + new Email { Type = "personal", Value="mdaldo@personal.domain", Primary = false, UserId = 1 }, + new Email { Type = "work", Value="dslem@fake.domain", Primary = true, UserId = 2 }, + new Email { Type = "work", Value="smahesh@fake.domain", Primary = true, UserId = 3 } + }); + base.OnModelCreating(modelBuilder); + } +} +``` + +Add dbconfiguration in the `Properties/appsettings.json` file as a top-level property. +```json +"ConnectionStrings": { + "DefaultConnection": "Data Source=scim-dev.db;" +} +``` + +This command above creates a db file in the project folder named `scim-db.dev`. After adding this property, the JSON file should look like this: +```json +{ + "ConnectionStrings": { + "DefaultConnection": "Data Source=scim-dev.db;" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} +``` +Now we can wire it all up in `Program.cs` for DI using `AddDbContextPool` middleware +- At the top of the file, add dependencies with `using` statements + ```c# + using Microsoft.EntityFrameworkCore; + using okta_scim_server_dotnet; + ``` +- After `builder.Services.AddSwaggerGen();` add the following code: + ```c# + builder.Services.AddDbContextPool( + options => options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")) + ); + ``` +The `Program.cs` file should now look like this: + +```c# +using Microsoft.EntityFrameworkCore; +using okta_scim_server_dotnet; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddDbContextPool( + options => options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")) +); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +var summaries = new[] +{ + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" +}; + +app.MapGet("/weatherforecast", () => +{ + var forecast = Enumerable.Range(1, 5).Select(index => + new WeatherForecast + ( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + Random.Shared.Next(-20, 55), + summaries[Random.Shared.Next(summaries.Length)] + )) + .ToArray(); + return forecast; +}) +.WithName("GetWeatherForecast") +.WithOpenApi(); + +app.Run(); + +record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} +``` + +We can then migrate the coded database models and data into the actual database. +- Prepare db migration by running `dotnet ef migrations add InitialScimDb` +- Apply these changes to db by running `dotnet ef database update`. This command would have created a file named `scim-dev.db` in your project folder. +- *Optional:* Test db creation using the command line tool, + - You should have [sqlite3](https://www.sqlite.org/index.html) client installed. (I had this our of the box in Mac OS) + - Connect using`sqlite3 <>/scim-dev.db` + - List tables using `.tables` + - List users by running `select * from Users;` + - Then exit using `.exit` + +### Create SCIM complaint request and response models +Having taken care of data models and database creation, let's move on to creating SCIM-compliant models. Our requests and responses will be using these models to communicate with SCIM clients. Create a new file, `ScimModels.cs`, and add the following SCIM models: +- `ScimListResourceRequest` is used when listing resources. It has pagination and filtering parameters. +- `ScimListResourceResponse` is used when returning a list of resources. Since this is a generic model, it can therefore be used for multiple resource types. +- `ScimErrorResponse` is used when returning an error such as resource not found in standard SCIM format. +- `ScimUser`, `ScimName`, and `ScimEmail` are user objects and child objects which are SCIM compliant. + +```c# +namespace okta_scim_server_dotnet; + +public class ScimListResourceRequest +{ + public string? filter { get; set; } + public int? startIndex { get; set; } + public int? count { get; set; } + // TODO: Start with simple parsing on what Okta sends. Extend it to be generic to handle other operations + public Dictionary parsedFilter + { + get + { + Dictionary parsedValue = new Dictionary(); + if (!string.IsNullOrWhiteSpace(filter)) + { + var filterTerms = filter.Split(" eq "); + if (filterTerms.Length == 2) + { + parsedValue.Add(filterTerms[0], filterTerms[1].Substring(1, filterTerms[1].Length - 2)); + } + } + return parsedValue; + } + } + public int parsedStartIndex { get { return startIndex ?? 1; } } + public int parsedCount { get { return count ?? 100; } } +} + +public class ScimListResourceResponse +{ + public IEnumerable schemas = new[] { "urn:ietf:params:scim:api:messages:2.0:ListResponse" }; + public int totalResults { get; set; } + public int startIndex { get; set; } + public int itemsPerPage { get; set; } + public IEnumerable Resources { get; set; } +} + +public class ScimErrorResponse +{ + public ScimErrorResponse(int status, string detail) + { + this.schemas = new[] { "urn:ietf:params:scim:api:messages:2.0:Error" }; + this.status = status; + this.detail = detail; + } + public IEnumerable schemas {get; private set;} + public string? detail { get; set; } + public int status { get; set; } +} +public class ScimUser +{ + public IEnumerable schemas { get; set; } + public string? id { get; set; } + public string externalId { get; set; } + public string userName { get; set; } + public ScimName name { get; set; } + public string displayName { get; set; } + public IEnumerable emails { get; set; } + public bool active { get; set; } +} + +public class ScimName +{ + public string givenName { get; set; } + public string familyName { get; set; } + public string? middleName { get; set; } +} + +public class ScimEmail +{ + public string value { get; set; } + public string type { get; set; } + public bool primary { get; set; } +} +``` +We also need to create mappers between DB models and SCIM models to avoid many manual conversions. For this, we will use the `AutoMapper` package. +- Install dependencies by running the following commands: + ``` + dotnet add package AutoMapper + dotnet add package Automapper.Extensions.Microsoft.DependencyInjection + ``` +- Add Mappings to `ScimModels.cs`: + - At the top of the file, add the `using` statement + ```c# + using AutoMapper; + ``` + - At the bottom, add the following code: + ```c# + public class UserProfile: Profile + { + public UserProfile() + { + CreateMap() + .ForMember(dest => dest.FirstName, act => act.MapFrom(src => src.name.givenName)) + .ForMember(dest => dest.LastName, act => act.MapFrom(src => src.name.familyName)) + .ForMember(dest => dest.MiddleName, act => act.MapFrom(src => src.name.middleName)) + .ReverseMap() + .ForPath(dest => dest.id, act => act.MapFrom(src => src.Id)) + .ForPath(dest => dest.schemas, act => act.MapFrom(src => new[] { "urn:ietf:params:scim:schemas:core:2.0:User" })); + + CreateMap().ReverseMap(); + } + } + ``` +The `ScimModels.cs` file should now look like this: + +```c# +using AutoMapper; + +namespace okta_scim_server_dotnet; + +public class ScimListResourceRequest +{ + public string? filter { get; set; } + public int? startIndex { get; set; } + public int? count { get; set; } + // TODO: Starting with simple parsing on what okta sends. Extend it to be generic to handle other operations + public Dictionary parsedFilter + { + get + { + Dictionary parsedValue = new Dictionary(); + if (!string.IsNullOrWhiteSpace(filter)) + { + var filterTerms = filter.Split(" eq "); + if (filterTerms.Length == 2) + { + parsedValue.Add(filterTerms[0], filterTerms[1].Substring(1, filterTerms[1].Length - 2)); + } + } + return parsedValue; + } + } + public int parsedStartIndex { get { return startIndex ?? 1; } } + public int parsedCount { get { return count ?? 100; } } +} + +public class ScimListResourceResponse +{ + public IEnumerable schemas = new[] { "urn:ietf:params:scim:api:messages:2.0:ListResponse" }; + public int totalResults { get; set; } + public int startIndex { get; set; } + public int itemsPerPage { get; set; } + public IEnumerable Resources { get; set; } +} + +public class ScimErrorResponse +{ + public ScimErrorResponse(int status, string detail) + { + this.schemas = new[] { "urn:ietf:params:scim:api:messages:2.0:Error" }; + this.status = status; + this.detail = detail; + } + public IEnumerable schemas {get; private set;} + public string? detail { get; set; } + public int status { get; set; } +} +public class ScimUser +{ + public IEnumerable schemas { get; set; } + public string? id { get; set; } + public string externalId { get; set; } + public string userName { get; set; } + public ScimName name { get; set; } + public string displayName { get; set; } + public IEnumerable emails { get; set; } + public bool active { get; set; } +} + +public class ScimName +{ + public string givenName { get; set; } + public string familyName { get; set; } + public string? middleName { get; set; } +} + +public class ScimEmail +{ + public string value { get; set; } + public string type { get; set; } + public bool primary { get; set; } +} +public class UserProfile: Profile +{ + public UserProfile() + { + CreateMap() + .ForMember(dest => dest.FirstName, act => act.MapFrom(src => src.name.givenName)) + .ForMember(dest => dest.LastName, act => act.MapFrom(src => src.name.familyName)) + .ForMember(dest => dest.MiddleName, act => act.MapFrom(src => src.name.middleName)) + .ReverseMap() + .ForPath(dest => dest.id, act => act.MapFrom(src => src.Id)) + .ForPath(dest => dest.schemas, act => act.MapFrom(src => new[] { "urn:ietf:params:scim:schemas:core:2.0:User" })); + + CreateMap().ReverseMap(); + } +} +``` + +- To wire up the SCIM models and the mappers we have created, `AddAutoMapper` middleware in `Program.cs` + - At the top of the file, add the `using` statements + ```c# + using AutoMapper; + ``` + - After `builder.Services.AddDbContextPool(...);` add the following code + ```c# + builder.Services.AddAutoMapper(typeof(Program).Assembly); + ``` + +The `Program.cs` file should now look like this: + +```c# +using Microsoft.EntityFrameworkCore; +using okta_scim_server_dotnet; +using AutoMapper; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddDbContextPool( + options => options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")) +); + +builder.Services.AddAutoMapper(typeof(Program).Assembly); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +var summaries = new[] +{ + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" +}; + +app.MapGet("/weatherforecast", () => +{ + var forecast = Enumerable.Range(1, 5).Select(index => + new WeatherForecast + ( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + Random.Shared.Next(-20, 55), + summaries[Random.Shared.Next(summaries.Length)] + )) + .ToArray(); + return forecast; +}) +.WithName("GetWeatherForecast") +.WithOpenApi(); + +app.Run(); + +record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} +``` + +### Create the .NET SCIM server RESTful API endpoints +Since we have created the necessary data and SCIM models. We can move on to creating the endpoints for user lifecycle management. First, let's set up dependencies, some basic global configuration, and clean up unnecessary code. +- Install dependencies by running `dotnet add package Newtonsoft.Json` +- At the top of the `Program.cs` file, add `using` statements + ```c# + using System.Text.Json.Serialization; + using System.Text.Json; + using System.Net; + using Microsoft.AspNetCore.Mvc; + ``` +- Add the following code after `var builder = WebApplication.CreateBuilder(args);` to respond cleanly and overcome parsing limitations + ```c# + builder.Services.ConfigureHttpJsonOptions(options => { + options.SerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; + options.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; + }); + ``` +- Remove WeatherForecast related sample code + +The `Program.cs` file should now look like this: + +```c# +using Microsoft.EntityFrameworkCore; +using okta_scim_server_dotnet; +using AutoMapper; +using System.Text.Json.Serialization; +using System.Text.Json; +using System.Net; +using Microsoft.AspNetCore.Mvc; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.ConfigureHttpJsonOptions(options => { + options.SerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; + options.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; +}); + +// Add services to the container. +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddDbContextPool( + options => options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")) +); + +builder.Services.AddAutoMapper(typeof(Program).Assembly); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.Run(); +``` + +### Retrieve a user +We are using minimal APIs for creating endpoints. For retrieving a user, let's create the route that expects the user's `id` in the path and responds with a single `ScimUser` object if found and a `ScimErrorResponse` if the user doesn't exist. Add the following code before `app.Run();` in the `Program.cs` file: +```c# +var scimPrefix = "/scim/v2"; +var userRoute = $"{scimPrefix}/users"; +var notFoundResponse = new ScimErrorResponse((int)HttpStatusCode.NotFound, "Resource Not Found"); +app.MapGet(userRoute + "/{id}", async Task (int id, ScimDbContext db, IMapper mapper) => { + ScimUser? user = await db.Users.Where(u => u.Id == id).Include(u => u.Emails).Select(u => mapper.Map(u)).FirstOrDefaultAsync(); + if(user is null) + { + return Results.NotFound(notFoundResponse); + } + return Results.Ok(user); +}) +.WithName("GetUser") +.WithOpenApi(); +``` + +The `Program.cs` file should now look like this: + +```c# +//..........Omitted for brevity.......... + +builder.Services.AddAutoMapper(typeof(Program).Assembly); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +var scimPrefix = "/scim/v2"; +var userRoute = $"{scimPrefix}/users"; +var notFoundResponse = new ScimErrorResponse((int)HttpStatusCode.NotFound, "Resource Not Found"); +app.MapGet(userRoute + "/{id}", async Task (int id, ScimDbContext db, IMapper mapper) => { + ScimUser? user = await db.Users.Where(u => u.Id == id).Include(u => u.Emails).Select(u => mapper.Map(u)).FirstOrDefaultAsync(); + if(user is null) + { + return Results.NotFound(notFoundResponse); + } + return Results.Ok(user); +}) +.WithName("GetUser") +.WithOpenApi(); + +app.Run(); +``` + +Some variables are declared outside of this method for reuse. + +Run the project using the steps mentioned in the [testing](#testing) section. To expand it, click on the `GET /scim/v2/users/{id}` endpoint. Click `Try it out` and enter `3` as the `id` value in the description box, then click the `Execute` button. You should receive the following as a response based on the seeding data with status code `200`: + +```json +{ + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User" + ], + "id": "3", + "userName": "smahesh@fake.domain", + "name": { + "givenName": "Sarika", + "familyName": "Mahesh" + }, + "displayName": "Sarika Mahesh", + "emails": [ + { + "value": "smahesh@fake.domain", + "type": "work", + "primary": true + } + ], + "active": true +} +``` + +Now change `id` to `100` and try again. You should receive the following as a response with status code `404`: +```json +{ + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:Error" + ], + "detail": "Resource Not Found", + "status": 404 +} +``` +We've completed our testing for the GET user endpoint. Let's add other endpoints! + +### List all users +For listing users, add the following code to the `Program.cs` file below the previously added section. This method expects `ScimListResourceRequest` attributes in the query and responds with `200` status and `ScimListResourceResponse` user in body. Okta will use pagination to retrieve large sets of users: + +```c# +app.MapGet(userRoute, async ([AsParameters] ScimListResourceRequest request, ScimDbContext db, IMapper mapper) => { + string filterUsername = request.parsedFilter.Where(f => f.Key.ToLower() == "username").Select(f => f.Value).SingleOrDefault(); + // filter and paginate results based on input + var users = await db.Users.Where(u => string.IsNullOrWhiteSpace(filterUsername) || u.UserName == filterUsername).OrderByDescending(u => u.Id).Include(u => u.Emails).ToListAsync(); + return new ScimListResourceResponse + { + totalResults = users.Count, + startIndex = request.parsedStartIndex, + itemsPerPage = request.parsedCount, + Resources = users.Skip(request.parsedStartIndex - 1).Take(request.parsedCount).Select(u => mapper.Map(u)) + }; +}) +.WithName("ListUsers") +.WithOpenApi(); +``` + +The `Program.cs` file should now look like this: + +```c# +//..........Omitted for brevity.......... + +var scimPrefix = "/scim/v2"; +var userRoute = $"{scimPrefix}/users"; +var notFoundResponse = new ScimErrorResponse((int)HttpStatusCode.NotFound, "Resource Not Found"); +app.MapGet(userRoute + "/{id}", async Task (int id, ScimDbContext db, IMapper mapper) => { + ScimUser? user = await db.Users.Where(u => u.Id == id).Include(u => u.Emails).Select(u => mapper.Map(u)).FirstOrDefaultAsync(); + if(user is null) + { + return Results.NotFound(notFoundResponse); + } + return Results.Ok(user); +}) +.WithName("GetUser") +.WithOpenApi(); + +app.MapGet(userRoute, async ([AsParameters] ScimListResourceRequest request, ScimDbContext db, IMapper mapper) => { + string filterUsername = request.parsedFilter.Where(f => f.Key.ToLower() == "username").Select(f => f.Value).SingleOrDefault(); + // filter and paginate results based on input + var users = await db.Users.Where(u => string.IsNullOrWhiteSpace(filterUsername) || u.UserName == filterUsername).OrderByDescending(u => u.Id).Include(u => u.Emails).ToListAsync(); + return new ScimListResourceResponse + { + totalResults = users.Count, + startIndex = request.parsedStartIndex, + itemsPerPage = request.parsedCount, + Resources = users.Skip(request.parsedStartIndex - 1).Take(request.parsedCount).Select(u => mapper.Map(u)) + }; +}) +.WithName("ListUsers") +.WithOpenApi(); + +app.Run(); +``` + +To test this, you'll need to rebuild the .NET SCIM server every time you add a new endpoint. To do this, enter `Ctrl+R` on your terminal, where you have your .NET SCIM server running. Once the server has been refreshed, click on the `GET /scim/v2/users` endpoint and the `Try it out` button. Then click the `Execute` button to run the request without any parameters. The code has some defaults and will return the first 100 users. You can expect a response with `200` status and body similar to the following: + +```json +{ + "totalResults": 4, + "startIndex": 1, + "itemsPerPage": 100, + "resources": [ + { + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User" + ], + "id": "4", + "userName": "fakeguy@fake.domain", + "name": { + "givenName": "Fake", + "familyName": "Guy" + }, + "displayName": "Fake Guy", + "emails": [], + "active": true + }, + ... //Removed for brevity + ] +} +``` +You can explore by adding different numerical values in `startIndex`, `count`, and also some valid `filter` parameter such as `userName eq "fakeguy@fake.domain"` + +### Create a user +To create a user, add the following code to the `Program.cs` file below the previously added section. This method expects a `ScimUser` object in the request body: + +```c# +app.MapPost(userRoute, async Task (ScimUser scimUser, ScimDbContext db, IMapper mapper) => { + var user = mapper.Map(scimUser); + db.Users.Add(user); + await db.SaveChangesAsync(); + return Results.Created($"users/{user.Id}", mapper.Map(user)); +}) +.WithName("CreateUser") +.WithOpenApi(); +``` + +The `Program.cs` file should now look like this: + +```c# +//..........Omitted for brevity.......... + +app.MapGet(userRoute, async ([AsParameters] ScimListResourceRequest request, ScimDbContext db, IMapper mapper) => { + string filterUsername = request.parsedFilter.Where(f => f.Key.ToLower() == "username").Select(f => f.Value).SingleOrDefault(); + // filter and paginate results based on input + var users = await db.Users.Where(u => string.IsNullOrWhiteSpace(filterUsername) || u.UserName == filterUsername).OrderByDescending(u => u.Id).Include(u => u.Emails).ToListAsync(); + return new ScimListResourceResponse + { + totalResults = users.Count, + startIndex = request.parsedStartIndex, + itemsPerPage = request.parsedCount, + Resources = users.Skip(request.parsedStartIndex - 1).Take(request.parsedCount).Select(u => mapper.Map(u)) + }; +}) +.WithName("ListUsers") +.WithOpenApi(); + +app.MapPost(userRoute, async Task (ScimUser scimUser, ScimDbContext db, IMapper mapper) => { + var user = mapper.Map(scimUser); + db.Users.Add(user); + await db.SaveChangesAsync(); + return Results.Created($"users/{user.Id}", mapper.Map(user)); +}) +.WithName("CreateUser") +.WithOpenApi(); + +app.Run(); + +``` + +To test this, you'll need to rebuild the .NET SCIM server everytime you add a new endpoint, to do this enter `Ctrl+R` on your terminal where you have your .NET SCIM server running. Click on the `POST/scim/v2/users` endpoint and the `Try it out` button, then enter the following JSON payload as the request body in Swagger UI before clicking the `Execute` button: + +```json +{ + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User" + ], + "userName": "fakeguy@fake.domain", + "name": { + "givenName": "Fake", + "familyName": "Guy" + }, + "displayName": "Fake Guy", + "active": true +} +``` +The expected response will be something like below with `201` status +```json +{ + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User" + ], + "id": "4", + "userName": "fakeguy@fake.domain", + "name": { + "givenName": "Fake", + "familyName": "Guy" + }, + "displayName": "Fake Guy", + "emails": [], + "active": true +} +``` + +### Update a user +To update a user, add the following code to the `Program.cs` file below the previously added section. We'll a `PUT` operation, but you can also develop a `PATCH` request. This operation expects the user's `id` in the path and the `ScimUser` object in the body, which can return either a `200` or `404` response: + +```c# +app.MapPut(userRoute + "/{id}", async Task (int id,ScimUser scimUser, ScimDbContext db, IMapper mapper) => { + var existingUser = await db.Users.Where(u => u.Id == id).Include(u => u.Emails).FirstOrDefaultAsync(); + if (existingUser is null) { return Results.NotFound(notFoundResponse); } + db.Entry(existingUser).CurrentValues.SetValues(mapper.Map(scimUser)); + foreach (var email in existingUser.Emails.ToList()) + { + if(!scimUser.emails.Any(u => u.value == email.Value)) { db.Emails.Remove(email); } + } + foreach (var email in scimUser.emails) + { + var existingEmail = existingUser.Emails.Where(e => e.Value == email.value).SingleOrDefault(); + if(existingEmail is not null) + { + db.Entry(existingEmail).CurrentValues.SetValues(email); + } else + { + existingUser.Emails.Add(mapper.Map(email)); + } + } + await db.SaveChangesAsync(); + return Results.Ok(mapper.Map(existingUser)); +}) +.WithName("UpdateUser") +.WithOpenApi(); +``` + +The `Program.cs` file should now look like this: + +```c# +//..........Omitted for brevity.......... + +app.MapPost(userRoute, async Task (ScimUser scimUser, ScimDbContext db, IMapper mapper) => { + var user = mapper.Map(scimUser); + db.Users.Add(user); + await db.SaveChangesAsync(); + return Results.Created($"users/{user.Id}", mapper.Map(user)); +}) +.WithName("CreateUser") +.WithOpenApi(); + +app.MapPut(userRoute + "/{id}", async Task (int id,ScimUser scimUser, ScimDbContext db, IMapper mapper) => { + var existingUser = await db.Users.Where(u => u.Id == id).Include(u => u.Emails).FirstOrDefaultAsync(); + if (existingUser is null) { return Results.NotFound(notFoundResponse); } + db.Entry(existingUser).CurrentValues.SetValues(mapper.Map(scimUser)); + foreach (var email in existingUser.Emails.ToList()) + { + if(!scimUser.emails.Any(u => u.value == email.Value)) { db.Emails.Remove(email); } + } + foreach (var email in scimUser.emails) + { + var existingEmail = existingUser.Emails.Where(e => e.Value == email.value).SingleOrDefault(); + if(existingEmail is not null) + { + db.Entry(existingEmail).CurrentValues.SetValues(email); + } else + { + existingUser.Emails.Add(mapper.Map(email)); + } + } + await db.SaveChangesAsync(); + return Results.Ok(mapper.Map(existingUser)); +}) +.WithName("UpdateUser") +.WithOpenApi(); + +app.Run(); +``` + +To test this, again, you'll need to rebuild the .NET SCIM server, so enter `Ctrl+R` on the terminal where you have your .NET SCIM server running. Let's update Fake Guy's profile to include his middle initial R. To do this, click on the `PUT/scim/v2/users/{id}` endpoint and the `Try it out` button, then enter `4` as the `id` value and the following JSON payload as the request body in the Swagger UI before clicking the `Execute` button: + +```json +{ + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User" + ], + "id": "4", + "userName": "fakeguy@fake.domain", + "name": { + "givenName": "Fake", + "familyName": "Guy", + "middleName": "R" + }, + "displayName": "Fake Guy", + "emails": [], + "active": true +} +``` +The expected response will be something like below with `200` status. +```json +{ + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User" + ], + "id": "4", + "userName": "fakeguy@fake.domain", + "name": { + "givenName": "Fake", + "familyName": "Guy", + "middleName": "R" + }, + "displayName": "Fake Guy", + "emails": [], + "active": true +} +``` +Feel free to test it with an invalid user id to get a `404` response. + +### Delete a user +Add the following code to the `Program.cs` file below the previously added section for deleting user. This endpoint is not a true delete endpoint but rather a soft-delete that uses the `PATCH` operation by setting the property `active: false`. This operation expects the user's `id` in the path and the `JsonPatchDocument` object in the body which can return either a `200` or `404` response. + +```c# +app.MapPatch(userRoute + "/{id}", async Task (int id, [FromBody] JsonDocument patchJson, ScimDbContext db, IMapper mapper) => { + var existingUser = await db.Users.Where(u => u.Id == id).Include(u => u.Emails).FirstOrDefaultAsync(); + if (existingUser is null) { return Results.NotFound(notFoundResponse); } + + foreach(JsonElement operation in patchJson.RootElement.GetProperty("Operations").EnumerateArray()) { + // Handling only active property for simplicity + if (operation.GetProperty("op").GetString() == "replace" && operation.GetProperty("value").TryGetProperty("active", out var temp)) { + existingUser.Active = operation.GetProperty("value").GetProperty("active").GetBoolean(); + await db.SaveChangesAsync(); + } + } + return Results.Ok(mapper.Map(existingUser)); +}) +.WithName("UpdateUserPartial") +.WithOpenApi(); +``` + +The `Program.cs` file should now look like this: + +```c# +//..........Omitted for brevity.......... + +app.MapPut(userRoute + "/{id}", async Task (int id,ScimUser scimUser, ScimDbContext db, IMapper mapper) => { + var existingUser = await db.Users.Where(u => u.Id == id).Include(u => u.Emails).FirstOrDefaultAsync(); + if (existingUser is null) { return Results.NotFound(notFoundResponse); } + db.Entry(existingUser).CurrentValues.SetValues(mapper.Map(scimUser)); + foreach (var email in existingUser.Emails.ToList()) + { + if(!scimUser.emails.Any(u => u.value == email.Value)) { db.Emails.Remove(email); } + } + foreach (var email in scimUser.emails) + { + var existingEmail = existingUser.Emails.Where(e => e.Value == email.value).SingleOrDefault(); + if(existingEmail is not null) + { + db.Entry(existingEmail).CurrentValues.SetValues(email); + } else + { + existingUser.Emails.Add(mapper.Map(email)); + } + } + await db.SaveChangesAsync(); + return Results.Ok(mapper.Map(existingUser)); +}) +.WithName("UpdateUser") +.WithOpenApi(); + +app.MapPatch(userRoute + "/{id}", async Task (int id, [FromBody] JsonDocument patchJson, ScimDbContext db, IMapper mapper) => { + var existingUser = await db.Users.Where(u => u.Id == id).Include(u => u.Emails).FirstOrDefaultAsync(); + if (existingUser is null) { return Results.NotFound(notFoundResponse); } + + foreach(JsonElement operation in patchJson.RootElement.GetProperty("Operations").EnumerateArray()) { + // Handling only active property for simplicity + if (operation.GetProperty("op").GetString() == "replace" && operation.GetProperty("value").TryGetProperty("active", out var temp)) { + existingUser.Active = operation.GetProperty("value").GetProperty("active").GetBoolean(); + await db.SaveChangesAsync(); + } + } + return Results.Ok(mapper.Map(existingUser)); +}) +.WithName("UpdateUserPartial") +.WithOpenApi(); + +app.Run(); +``` + +To test this, again you'll need to rebuild the SCIM server so enter `Ctrl+R` on your terminal where you have your SCIM server running. Let's deactivate Fake Guy's profile. To do this, click on the `PATCH/scim/v2/users/{id}` endpoint and the `Try it out` button, then enter `4` as the `id` value and the following JSON payload in the Swagger UI before clicking the `Execute` button: + +```json +{ + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:PatchOp" + ], + "Operations": [ + { + "op": "replace", + "value": { + "active": false + } + } + ] +} +``` +The expected response will be something like below with `200` status. +```json +{ + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User" + ], + "id": "4", + "userName": "fakeguy@fake.domain", + "name": { + "givenName": "Fake", + "familyName": "Guy", + "middleName": "R" + }, + "displayName": "Fake Guy", + "emails": [], + "active": false +} +``` +Feel free to test it out with an invalid user id to get a `404` response. + +## Secure your .NET SCIM server endpoints + +Now that endpoints are created and tested. It is time to secure it before integration with Okta. Okta recommends at least one of three ways to secure your server. For this sample, we will use *oAuth* using the `Okta.AspNetCore` package. +- Install dependency by running `dotnet add package Okta.AspNetCore` +- Add okta configuration in `Properties/appsettings.json` file as a top-level property + ```json + "Okta": { + "OktaDomain": "https://${yourOktaDomain}.com", + "AuthorizationServerId": "${authorizationServerId}" + }, + ``` +The `Properties/appsettings.json` file should now look like this: + +```json +{ + "Okta": { + "OktaDomain": "https://${yourOktaDomain}.com", + "AuthorizationServerId": "${authorizationServerId}" +}, + "ConnectionStrings": { + "DefaultConnection": "Data Source=scim-dev.db;" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} +``` + +If you've created an application through the Okta CLI (from the Setup Okta steps), then set your Authorization Server Id to the default template Authorization Server like this: + +```json +{ +"AuthorizationServerId": "default" +} +``` + +Another place to find the Authorization Server Id is in your Okta Dashboard under Security > API > Authorization Servers > Name of the Authorization Server, e.g., "default." + +- Make the following changes in `Program.cs` to implement authentication using `AddOktaWebApi` middleware. + - At the top of the file, add the `using` statement: + ```c# + using Okta.AspNetCore; + ``` + - Add the following code after `builder.Services.AddAutoMapper(...);`: + ```c# + builder.Services + .AddAuthentication(options => + { + options.DefaultAuthenticateScheme = OktaDefaults.ApiAuthenticationScheme; + options.DefaultChallengeScheme = OktaDefaults.ApiAuthenticationScheme; + options.DefaultSignInScheme = OktaDefaults.ApiAuthenticationScheme; + }) + .AddOktaWebApi(new OktaWebApiOptions + { + OktaDomain = builder.Configuration["okta:OktaDomain"], + AuthorizationServerId = builder.Configuration["okta:AuthorizationServerId"] + }); + builder.Services.AddAuthorization(); + ``` + - Add the following code before `app.UseHttpsRedirection();`: + ```c# + app.UseAuthentication(); + app.UseAuthorization(); + ``` +The `Program.cs` file should now look like this: + +```c# +using Microsoft.EntityFrameworkCore; +using okta_scim_server_dotnet; +using AutoMapper; +using System.Text.Json.Serialization; +using System.Text.Json; +using System.Net; +using Microsoft.AspNetCore.Mvc; +using Okta.AspNetCore; + + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.ConfigureHttpJsonOptions(options => { + options.SerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; + options.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; +}); + +// Add services to the container. +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddDbContextPool( + options => options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")) +); + +builder.Services.AddAutoMapper(typeof(Program).Assembly); +builder.Services + .AddAuthentication(options => + { + options.DefaultAuthenticateScheme = OktaDefaults.ApiAuthenticationScheme; + options.DefaultChallengeScheme = OktaDefaults.ApiAuthenticationScheme; + options.DefaultSignInScheme = OktaDefaults.ApiAuthenticationScheme; + }) + .AddOktaWebApi(new OktaWebApiOptions + { + OktaDomain = builder.Configuration["okta:OktaDomain"], + AuthorizationServerId = builder.Configuration["okta:AuthorizationServerId"] + }); +builder.Services.AddAuthorization(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); +app.UseAuthentication(); +app.UseAuthorization(); + +//..........Omitted for brevity.......... + +app.Run(); +``` + +- Next let's wire up this authentication requirement to all endpoints we developed. Simply add `RequireAuthorization()` to all routes. Here is an example for the POST endpoint (where `RequireAuthorization()` was added to the end of the code), + ```c# + app.MapPost(userRoute, async Task (ScimUser scimUser, ScimDbContext db, IMapper mapper) => { + var user = mapper.Map(scimUser); + db.Users.Add(user); + await db.SaveChangesAsync(); + return Results.Created($"users/{user.Id}", mapper.Map(user)); + }) + .WithName("CreateUser") + .WithOpenApi() + .RequireAuthorization(); + ``` +## Bootstrap OAuth to Swagger UI +Update `Okta` section in `Properties/appsettings.json` with the *Client ID* from earlier [section](#setup-okta-the-idp). + ```json + "Okta": { + "OktaDomain": "https://${yourOktaDomain}.com", + "AuthorizationServerId": "${authorizationServerId}", + "SwaggerClientId": "${clientId}" + } + ``` + +>**Note**: If you use the template authorization server called default as mentioned [here](https://developer.okta.com/docs/concepts/auth-servers/#default-custom-authorization-server), then set the AuthorizationServerId to the value "default". As shown below. + +The `Properties/appsettings.json` file should now look like this: + +```json +{ + "Okta": { + "OktaDomain": "https://${yourOktaDomain}.com", + "AuthorizationServerId": "default", + "SwaggerClientId": "${clientId}" +}, + "ConnectionStrings": { + "DefaultConnection": "Data Source=scim-dev.db;" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} +``` + +- In `Program.cs` and at the top of the file, add `using Microsoft.OpenApi.Models;` +- Update `builder.Services.AddSwaggerGen();` to + ```c# + builder.Services.AddSwaggerGen(c => + { + c.AddSecurityDefinition("oauth2", new OpenApiSecurityScheme + { + Type = SecuritySchemeType.OAuth2, + Flows = new OpenApiOAuthFlows + { + AuthorizationCode = new OpenApiOAuthFlow + { + AuthorizationUrl = new Uri($"{builder.Configuration["okta:OktaDomain"]}/oauth2/{builder.Configuration["okta:AuthorizationServerId"]}/v1/authorize"), + TokenUrl = new Uri($"{builder.Configuration["okta:OktaDomain"]}/oauth2/{builder.Configuration["okta:AuthorizationServerId"]}/v1/token"), + Scopes = new Dictionary{% raw %}{{ "openid", "openid" }, { "profile", "profile" }} {% endraw %} + } + } + }); + c.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "oauth2" }, + In = ParameterLocation.Header + }, + new List() + } + }); + }); + ``` +- Update `app.UseSwaggerUI();` to + ```c# + app.UseSwaggerUI(c => + { + c.OAuthClientId(builder.Configuration["okta:SwaggerClientId"]); + c.OAuthUsePkce(); + }); + ``` + +The `Program.cs` file should now look like this: + +```c# +using Microsoft.EntityFrameworkCore; +using okta_scim_server_dotnet; +using AutoMapper; +using System.Text.Json.Serialization; +using System.Text.Json; +using System.Net; +using Microsoft.AspNetCore.Mvc; +using Okta.AspNetCore; +using Microsoft.OpenApi.Models; + + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.ConfigureHttpJsonOptions(options => { + options.SerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; + options.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; +}); + +// Add services to the container. +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); + +builder.Services.AddSwaggerGen(c => + { + c.AddSecurityDefinition("oauth2", new OpenApiSecurityScheme + { + Type = SecuritySchemeType.OAuth2, + Flows = new OpenApiOAuthFlows + { + AuthorizationCode = new OpenApiOAuthFlow + { + AuthorizationUrl = new Uri($"{builder.Configuration["okta:OktaDomain"]}/oauth2/{builder.Configuration["okta:AuthorizationServerId"]}/v1/authorize"), + TokenUrl = new Uri($"{builder.Configuration["okta:OktaDomain"]}/oauth2/{builder.Configuration["okta:AuthorizationServerId"]}/v1/token"), + Scopes = new Dictionary {% raw %}{{ "openid", "openid" }, { "profile", "profile" }} {% endraw %} + } + } + }); + c.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "oauth2" }, + In = ParameterLocation.Header + }, + new List() + } + }); + }); + +builder.Services.AddDbContextPool( + options => options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")) +); + +builder.Services.AddAutoMapper(typeof(Program).Assembly); +builder.Services + .AddAuthentication(options => + { + options.DefaultAuthenticateScheme = OktaDefaults.ApiAuthenticationScheme; + options.DefaultChallengeScheme = OktaDefaults.ApiAuthenticationScheme; + options.DefaultSignInScheme = OktaDefaults.ApiAuthenticationScheme; + }) + .AddOktaWebApi(new OktaWebApiOptions + { + OktaDomain = builder.Configuration["okta:OktaDomain"], + AuthorizationServerId = builder.Configuration["okta:AuthorizationServerId"] + }); +builder.Services.AddAuthorization(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(c => + { + c.OAuthClientId(builder.Configuration["okta:SwaggerClientId"]); + c.OAuthUsePkce(); + }); +} + +app.UseHttpsRedirection(); +app.UseAuthentication(); +app.UseAuthorization(); + +var scimPrefix = "/scim/v2"; +var userRoute = $"{scimPrefix}/users"; +var notFoundResponse = new ScimErrorResponse((int)HttpStatusCode.NotFound, "Resource Not Found"); +app.MapGet(userRoute + "/{id}", async Task (int id, ScimDbContext db, IMapper mapper) => { + ScimUser? user = await db.Users.Where(u => u.Id == id).Include(u => u.Emails).Select(u => mapper.Map(u)).FirstOrDefaultAsync(); + if(user is null) + { + return Results.NotFound(notFoundResponse); + } + return Results.Ok(user); +}) +.WithName("GetUser") +.WithOpenApi(); + +app.MapGet(userRoute, async ([AsParameters] ScimListResourceRequest request, ScimDbContext db, IMapper mapper) => { + string filterUsername = request.parsedFilter.Where(f => f.Key.ToLower() == "username").Select(f => f.Value).SingleOrDefault(); + // filter and paginate results based on input + var users = await db.Users.Where(u => string.IsNullOrWhiteSpace(filterUsername) || u.UserName == filterUsername).OrderByDescending(u => u.Id).Include(u => u.Emails).ToListAsync(); + return new ScimListResourceResponse + { + totalResults = users.Count, + startIndex = request.parsedStartIndex, + itemsPerPage = request.parsedCount, + Resources = users.Skip(request.parsedStartIndex - 1).Take(request.parsedCount).Select(u => mapper.Map(u)) + }; +}) +.WithName("ListUsers") +.WithOpenApi() +.RequireAuthorization(); + +app.MapPost(userRoute, async Task (ScimUser scimUser, ScimDbContext db, IMapper mapper) => { + var user = mapper.Map(scimUser); + db.Users.Add(user); + await db.SaveChangesAsync(); + return Results.Created($"users/{user.Id}", mapper.Map(user)); +}) +.WithName("CreateUser") +.WithOpenApi() +.RequireAuthorization(); + +app.MapPut(userRoute + "/{id}", async Task (int id,ScimUser scimUser, ScimDbContext db, IMapper mapper) => { + var existingUser = await db.Users.Where(u => u.Id == id).Include(u => u.Emails).FirstOrDefaultAsync(); + if (existingUser is null) { return Results.NotFound(notFoundResponse); } + db.Entry(existingUser).CurrentValues.SetValues(mapper.Map(scimUser)); + foreach (var email in existingUser.Emails.ToList()) + { + if(!scimUser.emails.Any(u => u.value == email.Value)) { db.Emails.Remove(email); } + } + foreach (var email in scimUser.emails) + { + var existingEmail = existingUser.Emails.Where(e => e.Value == email.value).SingleOrDefault(); + if(existingEmail is not null) + { + db.Entry(existingEmail).CurrentValues.SetValues(email); + } else + { + existingUser.Emails.Add(mapper.Map(email)); + } + } + await db.SaveChangesAsync(); + return Results.Ok(mapper.Map(existingUser)); +}) +.WithName("UpdateUser") +.WithOpenApi() +.RequireAuthorization(); + +app.MapPatch(userRoute + "/{id}", async Task (int id, [FromBody] JsonDocument patchJson, ScimDbContext db, IMapper mapper) => { + var existingUser = await db.Users.Where(u => u.Id == id).Include(u => u.Emails).FirstOrDefaultAsync(); + if (existingUser is null) { return Results.NotFound(notFoundResponse); } + + foreach(JsonElement operation in patchJson.RootElement.GetProperty("Operations").EnumerateArray()) { + // Handling only active property for simplicity + if (operation.GetProperty("op").GetString() == "replace" && operation.GetProperty("value").TryGetProperty("active", out var temp)) { + existingUser.Active = operation.GetProperty("value").GetProperty("active").GetBoolean(); + await db.SaveChangesAsync(); + } + } + return Results.Ok(mapper.Map(existingUser)); +}) +.WithName("UpdateUserPartial") +.WithOpenApi() +.RequireAuthorization(); + +app.Run(); +``` + +- At this point, our OAuth implementation can now can be tested on our CRUD endpoints. However, this needs an additional step. The Swagger UI should now have an `Authorize` button. Click on this button, then click on the `select all` option under `Scopes` before clicking on the `Authorize` button again. If you do not authenticate, you will get `401` responses from any of the endpoints we've protected. + +## Test requests to the .NET SCIM server locally using a tunnel + +Okta will need to make requests to your server; therefore, you will need to expose your .NET SCIM server to the internet. One way to give your app a public URL or IP would be to host it on a cloud instance with DNS that you control. For development purposes, you can use ngrok or Localtunnel to provide a public address to the app running on your computer. In this project, we will be using [ngrok](https://ngrok.com/). Feel free to use any other tunneling tool such as [localtunnel](https://github.com/localtunnel/localtunnel). + +After setting up an account with ngrok: +1. Use the tunnel on a separate terminal from where you are running your .NET SCIM server. +2. Run `ngrok http <>` (you can get this port from *Properties/launchSettings.json*). +3. Note down the domain listed in the console (this is the *scim server domain*). +4. Open the web interface hosted on http://localhost:4040 to inspect the network traffic between Okta and the SCIM server. + +## Create a SCIM provisioning application in Okta + +Log in to your [Okta Developer Account](https://developer.okta.com/login/) that you created previously. + +In this step, you will create an app integration in Okta. Although there are several ways to create apps, we will demonstrate the workflow you would use if you decide to publish your SCIM integration to customers on the Okta Integration Network. Don't worry, we won't submit the sample app to the integration network! + +Log in to your Okta Developer Account. Ensure you're in the Admin Console — when you're in the Admin Console, the URL will be "dev-youraccountid-admin.okta.com". In the sidebar, select **Applications** > **Applications**, and press the **Browse App Catalog** button. + +Enter "**SCIM 2.0 Test App**" in the search box. Select the "**SCIM 2.0 Test App (OAuth Bearer Token)**", and press the **+ Add Integration** button to confirm creating an integration using "SCIM 2.0 Test App (Header Auth)". + +You'll then see configuration options. Leave the options under the **General Settings** tab and navigate to the **Sign-On Options** tab. Give the app a helpful name in the **Application label** field. For example, you can keep the default or change it to ".NET SCIM Server". Leave the **Automatically log in when a user lands on login page** checked, and click **Next**. + +Keep the default settings in the **Sign-On Options** dialog, as our app doesn't use these. Click the **Done** button at the bottom of the page. + +In the **Provisioning** tab of the application, press the **Configure API Integration** button and check the **Enable API Integration** checkbox. Provide the **Base URL**, which is the ngrok localtunnel URL with `/scim/v2` appended to the end. The **OAuth Bearer Token** is can be retrieved from the test you did above either from UI or curl. Press **Save** to save the settings. + +For example, below is a highlighted sample base URL from ngrok: + +{% img blog/net-scim/ngrok.jpeg alt:"Image of highlighted sample base URL from ngrok" width:"800" %}{: .center-image } + + When you save these settings or use the **Test API Credentials** button, Okta will make a `GET /Users` request with the OAuth token you provided to verify a connection with your .NET SCIM server. + +Finally, under the **Provisioning** tab, click the **To App** heading in the left sidebar.In the **Provisioning To App** settings, press the **Edit** button. Enable **Create Users**, **Update User Attributes**, and **Deactivate Users**. Leave the **Sync Passwords** setting unchecked for now. Press **Save**. Remain on this screen for the next steps. + +{% img blog/net-scim/provisioning.jpeg alt:"Image of the Okta Provisioning Tab with the option to create, update, deactivate users enabled." width:"800" %}{: .center-image } + +## Test common user lifecycle operations + +We are now ready to test common user lifecycle requests between Okta and your .NET SCIM server. + +### Import users from the .NET SCIM server + +As you know, we have existing users in our .NET SCIM server. Let's sync them over to Okta so that we have visibility of all users. We can also designate Okta as the source of truth for info about users assigned to this app or organization. + +In the **Import** tab of the SCIM Test App in Okta, click the **Import Now** button. You'll see a dialog confirming Okta scanned your users and found new users. You'll then have a screen to confirm assignments. When the import succeeds, Okta will request to get all users and confirm those already not recorded. In the list of users to import, check the boxes by those you want to sync to Okta and click **Confirm Assignments**. Then go to the **Assignments** tab and watch as the imported users appear. + +{% img blog/net-scim/import.jpeg alt:"Image of users detected by Okta during import." width:"800" %}{: .center-image } + +### Sync users from Okta to the .NET SCIM server + +To sync a user who exists in Okta but not our app, we'll first need to create a user in Okta. From within the Okta Admin Console sidebar, navigate to **Directory** > **People**. Press the **Add Person** button and add a person with the default **User type** of "User" with a **First name** of "Tom", **Last name** of "Anderson", **Username** of "tom.anderson@fake.domain", and press **Save**. + +You can now go back to your SCIM Application. Navigate to **Applications** > **Applications** in the sidebar. Find your SCIM app and press on the list item to edit it. In the **Assignments** tab, click the **Assign** button and select **Assign to People** in the dropdown menu. Click the **Assign** button next to "Tom Anderson" in the user list, and press **Save and Go Back** to accept the default values. Press the **Done** button to save your changes. On the Swagger UI, double-check that Tom is in the downstream server by making a GET /scim/v2/users request to list all current users. + +### Deprovision a user + +Let's say Tom's contract with the company ends, and you must deprovision him from the application. + +In the **Assignments** tab of the Okta SCIM application, find "Tom Anderson" and press the **X** button next to Tom's entry to unassign them from the app. This unassignment triggers Okta to send a PATCH request to your .NET SCIM server, setting the unassigned user's `active` attribute to `false` which indicates that a user's account is deactivated. + +We can confirm that Tom's `active` attribute is now `false` in the Swagger UI by making a request to the GET /scim/v2/users endpoint filtering for Tom's email "tom.anderson@fake.domain". + +### Reprovision a user + +Let's say Tom gets hired full-time with the company and needs access to the application again. + +To reactivate Tom's account, we will repeat the steps for assigning his Okta account to the application. Reactivating Tom causes Okta to send a PATCH request to your SCIM server, setting their `active` attribute to `true`. + +Again, we can confirm that Tom's account is now `active` through the Swagger UI by making a request to the GET Users endpoint filtering for Tom's email "tom.anderson@fake.domain". + +### Change a user's info in Okta + +Let's look at one more scenario. Let's say not only has Tom returned, but he also changed his name! Worry not, as our SCIM connection will automatically propagate this profile update. Let's change Tom's name to Leo and watch how Okta sends the new name to the .NET SCIM server. + +In the Okta Admin Console sidebar, navigate to **Directory** > **People**, and click on Tom's name in the user list to edit their settings. In the **Profile** tab, click **Edit** in the **Attributes** pane. Change the `firstName` field to "Leo", and update their `login` and `email` to `leo.anderson@fake.domain`. Use the **Save** button at the bottom of the page to save your changes. Again, on the Swagger UI, double-check Tom's updated name by making a GET /scim/v2/users request to list all current users. + +>**Troubleshooting**: If you experience import issues with Okta, double-check that your previous SCIM base URL isn't cached under the **Provisioning** > **Integration** tab. If it's cached, requests won't be made to your .NET SCIM server. + +## Keep experimenting with SCIM + +This tutorial provides the fundamental steps to create your .NET SCIM server to manage user lifecycle with an identity provider such as Okta. You can get the complete sample code for this project from [the GitHub repository](https://github.com/oktadev/okta-net-scim-example). Now, you can extend resources supported by adding groups. Or update the SCIM model to add more attributes you may need. + +You can go even further and create an application that authenticates users using the same identity provider to provide Single Sign On to users already provisioned through your .NET SCIM server. + +- [Enterprise-Ready Workshop: Manage users with SCIM](/blog/2023/07/28/scim-workshop) +- [Enterprise-Ready Workshop: Authenticate with OpenID Connect](/blog/2023/07/28/oidc_workshop) + +You can further manage your users and groups using Terraform or Workflows: +- [Enterprise Maturity Workshop: Terraform](/blog/2023/07/28/terraform-workshop) +- [Enterprise Ready Workshop: Automate with no-code Okta Workflows](/blog/2023/09/15/workflows-workshop) + +Excited to learn more about creating secure .NET apps? Follow us on [Twitter](https://twitter.com/oktadev) and subscribe to our [YouTube](https://www.youtube.com/c/oktadev) channel. If you have any questions or you want to share what tutorial you'd like to see next, please comment below!