Update 20, April 2023
This article is out of date because I added more functionality to this OAuth server, so to be more updated I suggest downloading the project or clone it from my GitHub repo here
Yeah, I know the title of this post is very interesting, and I hope you enjoy this journey to building your OAuth 2.0 Server and OpenId Connect Provider with me.
And I and you, we are a team.
But hey wait, there is also one member in our team, let me introduce you to him, Specification
I'm not going here to explain to you how the OAuth 2.0 and OpenId Connect works, but I will explain for you how to implement what inside specifications, at least the basics and fundamentals.
The Authorization Server that we are going to build is very simple but is complete one.
If you are not interesting to read this article don't worry you can download the completed source code from my Github repo from here
First of all, let me give you an overview about what we're going to build right here.
- Authorization Server (in ASP.NET Core MVC)
- ASP.NET Core MVC Application
First let us Create our Client application (ASP.NET Core MVC)
Open the Visual Studio and create an Empty ASP.NET Core App (see below) named PlatformNet6 (you can give it a name you like)
Choose NET6 (LTS) version (see the pic below)
Create a folder named Controllers and inside this folder create a new controller class named HomeController.cs
In the recent HomeController class you will find one Action Method named Index, Press right click on the name of the project and create a new folder named Views, back to Index Action Method on the HomeControllers.cs press right click on it and choose the Add View option from the menu.
The HomeController class should look like:
namespace PlatformNet6.Controllers
{
[Authorize]
public class HomeController : Controller
{
public IActionResult Index()
{
return View();
}
}
}
By using Nuget Packages install this Library:
Microsoft.AspNetCore.Authentication.OpenIdConnect
Open the Program.cs class and drop the code that shown below:
var builder = WebApplication.CreateBuilder(args);
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
builder.Services.AddAuthentication(config =>
{
config.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
config.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme)
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
// this is my Authorization Server Port
options.Authority = "https://localhost:7275";
options.ClientId = "platformnet6";
options.ClientSecret = "123456789";
options.ResponseType = "code";
options.CallbackPath = "/signin-oidc";
options.SaveTokens = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = false,
SignatureValidator = delegate(string token, TokenValidationParameters validationParameters)
{
var jwt = new JwtSecurityToken(token);
return jwt;
},
};
});
builder.Services.AddControllersWithViews();
var app = builder.Build();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapDefaultControllerRoute();
});
app.Run();
In the above code I use the AddOpenIdConnect extension that inform ASP.NET Core to redirect the app to the Authorization Server when find any Authorize attribute on any Action Method or Controller. I'm going to use Cookies as Default authentication scheme and OpenIdConnect as Default Challenge Scheme.
Oooooooops we finish our client app, we will back to this application to complete the content of the Index.cshtml page.
The Authorization Server Project
In this section we are going to build our Authorization Server and this section is the mail section in this Post.
Open Visual Studio and create an Empty ASP.NET Core App (see below) named OAuth20.Server (you can give it a name you like)
Please look at the name of the solution is OAuth20
Choose NET6 (LTS) version (see the pic below)
Create these folders in the root of our application:
- Controllers
- Common
- Endpoints
- Models
- OauthRequest
- OauthResponse
- Services
- Views
The structure of our Authorization Server looks like the below picture
By using Nuget Packages install this Library:
System.IdentityModel.Tokens.Jwt
If you look at the specification https://www.rfc-editor.org/rfc/rfc6749 the main role of the OAuth 2.0 is the Client and the client here is the Application that used by the Resource Owner and the Resource Owner is the person who used that application. For example, I'm Mohammed Ahmed Hussien and I used the Dev.to to read the latest articles about Software.
Here Dev.to is the Client and Mohammed Ahmed Hussien is the Resource Owner.
Inside Model folder create a new class named Client.cs
using System.Collections.Generic;
namespace OAuth20.Server.Models
{
public class Client
{
public Client()
{
}
public string ClientName { get; set; }
public string ClientId { get; set; }
/// <summary>
/// Client Password
/// </summary>
public string ClientSecret { get; set; }
public IList<string> GrantType { get; set; }
/// <summary>
/// by default false
/// </summary>
public bool IsActive { get; set; } = false;
public IList<string> AllowedScopes { get; set; }
public string ClientUri { get; set; }
public string RedirectUri { get; set; }
}
}
The specification says that the RedirectUri must accept an array of the URI, and as you see in our Client.cs class the RedirectUri is accept only string value not Array values. But I will promise you I will fix that later, trust me!
OpenId Connect build on top of the OAuth2.0 protocol and the main purpose of it is to authenticate the user. And if you remember we use the AddOpenIdConnect extension to authenicate our users in the PlatformNet6 application this extension scans for endpoint have name .well-known/openid-configuration and this endpoint shoud return json response with all the information that OpenId Connect needs, consult the OpenId Connect Docs for more details from here: https://openid.net/specs/openid-connect-discovery-1_0.html
Inside Endpoints folder create a new class named DiscoveryResponse.cs and paste the next code inside it:
using System.Collections.Generic;
namespace OAuth20.Server.Endpoints
{
public class DiscoveryResponse
{
public string issuer { get; set; }
public string authorization_endpoint { get; set; }
public string token_endpoint { get; set; }
public IList<string> token_endpoint_auth_methods_supported { get; set; }
public IList<string> token_endpoint_auth_signing_alg_values_supported { get; set; }
public string userinfo_endpoint { get; set; }
public string check_session_iframe { get; set; }
public string end_session_endpoint { get; set; }
public string jwks_uri { get; set; }
public string registration_endpoint { get; set; }
public IList<string> scopes_supported { get; set; }
public IList<string> response_types_supported { get; set; }
public IList<string> acr_values_supported { get; set; }
public IList<string> subject_types_supported { get; set; }
public IList<string> userinfo_signing_alg_values_supported { get; set; }
public IList<string> userinfo_encryption_alg_values_supported { get; set; }
public IList<string> userinfo_encryption_enc_values_supported { get; set; }
public IList<string> id_token_signing_alg_values_supported { get; set; }
public IList<string> id_token_encryption_alg_values_supported { get; set; }
public IList<string> id_token_encryption_enc_values_supported { get; set; }
public IList<string> request_object_signing_alg_values_supported { get; set; }
public IList<string> display_values_supported { get; set; }
public IList<string> claim_types_supported { get; set; }
public IList<string> claims_supported { get; set; }
public bool claims_parameter_supported { get; set; }
public string service_documentation { get; set; }
public IList<string> ui_locales_supported { get; set; }
}
}
The most important properties of this class is:
- issuer (is the domain name of the Identity Provider)
- authorization_endpoint (the endpoint that validate the client)
- token_endpoint (the return the Identity Token and the Access Token to the client) As I said before the AddOpenIdConnect extension scans for endpoint with name .well-known/openid-configuration, so let us create this endpoint
Inside the Controllers folder create a new controller class named DiscoveryEndpointController.cs paste the code that shown next:
using Microsoft.AspNetCore.Mvc;
using OAuth20.Server.Endpoints;
namespace OAuth20.Server.Controllers
{
public class DiscoveryEndpointController : Controller
{
// .well-known/openid-configuration
[HttpGet("~/.well-known/openid-configuration")]
public JsonResult GetConfiguration()
{
var response = new DiscoveryResponse
{
issuer = "https://localhost:7275",
authorization_endpoint = "https://localhost:7275/Home/Authorize",
token_endpoint = "https://localhost:7275/Home/Token",
token_endpoint_auth_methods_supported = new string[] { "client_secret_basic", "private_key_jwt" },
token_endpoint_auth_signing_alg_values_supported = new string[] { "RS256", "ES256" },
acr_values_supported = new string[] {"urn:mace:incommon:iap:silver", "urn:mace:incommon:iap:bronze"},
response_types_supported = new string[] { "code", "code id_token", "id_token", "token id_token" },
subject_types_supported = new string[] { "public", "pairwise" },
userinfo_encryption_enc_values_supported = new string[] { "A128CBC-HS256", "A128GCM" },
id_token_signing_alg_values_supported = new string[] { "RS256", "ES256", "HS256" },
id_token_encryption_alg_values_supported = new string[] { "RSA1_5", "A128KW" },
id_token_encryption_enc_values_supported = new string[] { "A128CBC-HS256", "A128GCM" },
request_object_signing_alg_values_supported = new string[] { "none", "RS256", "ES256" },
display_values_supported = new string[] { "page", "popup" },
claim_types_supported = new string[] { "normal", "distributed" },
scopes_supported = new string[] { "openid", "profile", "email", "address", "phone", "offline_access" },
claims_supported = new string[] { "sub", "iss", "auth_time", "acr", "name", "given_name",
"family_name", "nickname", "profile", "picture", "website", "email", "email_verified",
"locale", "zoneinfo" },
claims_parameter_supported = true,
service_documentation = "https://localhost:7275/connect/service_documentation.html",
ui_locales_supported = new string[] { "en-US", "en-GB", "en-CA", "fr-FR", "fr-CA" }
};
return Json(response);
}
}
}
The return type of this Action Method is josn result, and the name of this endpoint is decorated inside HttpGet attribute
[HttpGet("~/.well-known/openid-configuration")]
Be sure to change the Ports of the endpoints to that one Visual Studio created for you.
My own port is https://localhost:7275
To check if everything is works correctly hit a Breakpoint in this endpoint (.well-known/openid-configuration) and run the OAuth20.Server Authorization Server app, after that run the PlatformNet6 application, you will find the breakpoint is triggered.
Be sure of the setting at the PlatformNet6 application
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
// this is my Authorization Server Port
options.Authority = "https://localhost:7275";
options.ClientId = "platformnet6";
options.ClientSecret = "123456789";
options.ResponseType = "code";
options.CallbackPath = "/signin-oidc";
options.SaveTokens = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = false,
SignatureValidator = delegate(string token, TokenValidationParameters validationParameters)
{
var jwt = new JwtSecurityToken(token);
return jwt;
},
};
});
So far so good at this stage we prepare the Authorization Server to accept calls from any client.
Let us break the ice by creating a common C# extension Method
Inside Common folder create a new class named ExtensionMethods.cs this class has a signature as follow:
using System.ComponentModel;
using System;
using System.Linq;
namespace OAuth20.Server.Common
{
public static class ExtensionMethods
{
public static string GetEnumDescription(this Enum en)
{
if (en == null) return null;
var type = en.GetType();
var memberInfo = type.GetMember(en.ToString());
var description = (memberInfo[0].GetCustomAttributes(typeof(DescriptionAttribute),
false).FirstOrDefault() as DescriptionAttribute)?.Description;
return description;
}
public static bool IsRedirectUriStartWithHttps(this string redirectUri)
{
if(redirectUri != null && redirectUri.StartsWith("https")) return true;
return false;
}
}
}
Instead of creating a static class with const fields of type string I will use Enum type and any filed inside the Enum I will decorate it with the Description attribute, so If I need a value inside this attribute I will use GetEnumDescription extension method.
Inside Model folder create enum type named AuthorizationGrantTypesEnum.cs
using System.ComponentModel;
namespace OAuth20.Server.Models
{
internal enum AuthorizationGrantTypesEnum : byte
{
[Description("code")]
Code,
[Description("Implicit")]
Implicit,
[Description("ClientCredentials")]
ClientCredentials,
[Description("ResourceOwnerPassword")]
ResourceOwnerPassword
}
}
Back to Model folder the create a new class named GrantTypes.cs
using OAuth20.Server.Common;
using System.Collections.Generic;
namespace OAuth20.Server.Models
{
public class GrantTypes
{
public static IList<string> Code =>
new[] { AuthorizationGrantTypesEnum.Code.GetEnumDescription() };
public static IList<string> Implicit =>
new[] { AuthorizationGrantTypesEnum.Implicit.GetEnumDescription() };
public static IList<string> ClientCredentials =>
new[] { AuthorizationGrantTypesEnum.ClientCredentials.GetEnumDescription() };
public static IList<string> ResourceOwnerPassword =>
new[] { AuthorizationGrantTypesEnum.ResourceOwnerPassword.GetEnumDescription() };
}
}
Consult the specification for more details about this class from here https://www.rfc-editor.org/rfc/rfc6749#page-23
The second endpoint that the AddOpenIdConnect extension looks for it is the Authorization endpoint and the mission of this endpoint is validate the client. Before going deeper let us create our client store object, this object will hold all clients that would like to use our Authorization Server. Inside Model folder create a new class named ClientStore.cs and the content of this class it looks as follow:
using System.Collections.Generic;
namespace OAuth20.Server.Models
{
public class ClientStore
{
public IEnumerable<Client> Clients = new[]
{
new Client
{
ClientName = "platformnet .Net 6",
ClientId = platformnet6",
ClientSecret = "123456789",
AllowedScopes = new[]{ "openid", "profile"},
GrantType = GrantTypes.Code,
IsActive = true,
ClientUri = "https://localhost:7026",
RedirectUri = "https://localhost:7026/signin-oidc"
}
};
}
}
The ClientUri and RedirectUri it should mention the PlatformNet6 application (Client) that we created previously at beginning of this post. Be sure to replace the Port with the one that Visual Studio created for you, (Just the port). the ClientName should match the one that you introduce in the PlatformNet6 as well as ClientId and ClientSecret and GrantType
Please let us take a break and come back to complete this post, I feel tired, and I have to take a cup of tea
So, I'm here, and I'm so glad you are here also with me at this stage!
Let's creating the Authorization endpoint but before that I need to explain how this endpoint works.
If you remember when we configure the .well-known/openid-configuratio endpoint we assign https://localhost:7275/Home/Authorize as value of the authorization_endpoint property, so the OpenIdConnect extension scan for the endpoint with name .well-known/openid-configuratio and it expect to find in the response a location of the authorization_endpoint, like so:
authorization_endpoint = "https://localhost:7275/Home/Authorize"
The authorization_endpoint is the second place the AddOpenIdConnect looks for, and in that request the AddOpenIdConnect take more than one parameter with the current request (HTTPContext), let's take a look.
Create a new class inside OauthRequest folder named AuthorizationRequest.cs and the content of this class as follow:
namespace OAuth20.Server.OauthRequest
{
public class AuthorizationRequest
{
public AuthorizationRequest() { }
/// <summary>
/// Response Type, is required
/// </summary>
public string response_type { get; set; }
/// <summary>
/// Client Id, is required
/// </summary>
public string client_id { get; set; }
/// <summary>
/// Redirect Uri, is optional
/// The redirection endpoint URI MUST be an absolute URI as defined by
/// [RFC3986] Section 4.3
/// </summary>
public string redirect_uri { get; set; }
/// <summary>
/// Optional
/// </summary>
public string scope { get; set; }
/// <summary>
/// Return the state in the result
/// if it was present in the client authorization request
/// </summary>
public string state { get; set; }
}
}
So, let's dive in and create the Authorization endpoint.
Inside Controllers folder create a new controller named HomeController.cs, see the content of this controller below:
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using OAuth20.Server.OauthRequest;
namespace OAuth20.Server.Controllers
{
public class HomeController : Controller
{
private readonly IHttpContextAccessor _httpContextAccessor;
public HomeController(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public IActionResult Authorize(AuthorizationRequest authorizationRequest)
{
// The implementation goes here
}
public IActionResult Error(string error)
{
return View(error);
}
}
}
If look carefully I use the IHttpContextAccessor interface that provide access to the current HttpContext's Request.
You have to register this interface at Program.cs file like so:
builder.Services.AddHttpContextAccessor();
Inside HomeController class I defined two Action Method:
- Authorize
- Error
The last one is very simple if there is any error has been happening, then redirect the Resource Owner to the Error action method, this Action Method take one parameter of type string, and the name of this errors has standard name, let us create a Enum class take care of all these error name.
Inside OauthResponse folder create class named ErrorTypeEnum.cs as follow:
using System.ComponentModel;
namespace OAuth20.Server.OauthResponse
{
public enum ErrorTypeEnum : byte
{
[Description("invalid_request")]
InvalidRequest,
[Description("unauthorized_client")]
UnAuthoriazedClient,
[Description("access_denied")]
AccessDenied,
[Description("unsupported_response_type")]
UnSupportedResponseType,
[Description("invalid_scope")]
InValidScope,
[Description("server_error")]
ServerError,
[Description("temporarily_unavailable")]
TemporarilyUnAvailable,
[Description("invalid_grant")]
InvalidGrant,
[Description("invalid_client")]
InvalidClient
}
}
Consult the OAuth2.0 specification for more details about the error types
Back to Authorize endpoint (Action Method) in the HomeController class, in this endpoint we have to verify the Client request data that coming inside request with the one that we stored it inside the ClientStore.cs classBy the way you can store the client information at any back store like SQL Server Database, for simplicity I store it inside C# class.
The Client is expected from me a response result if the Authorize* endpoint verifies the Client successfully, for that we need a class take care of the property of that response, so inside OauthResponse folder create a new class named AuthorizeResponse.cs as follow:
using System.Collections.Generic;
namespace OAuth20.Server.OauthResponse
{
public class AuthorizeResponse
{
/// <summary>
/// code or implicit grant or client creditional
/// </summary>
public string ResponseType { get; set; }
public string Code { get; set; }
/// <summary>
/// required if it was present in the client authorization request
/// </summary>
public string State { get; set; }
public string RedirectUri { get; set; }
public IList<string> RequestedScopes { get; set; }
public string GrantType { get; set; }
public string Nonce { get; set; }
public string Error { get; set; } = string.Empty;
public string ErrorUri { get; set; }
public string ErrorDescription { get; set; }
public bool HasError => !string.IsNullOrEmpty(Error);
}
}
From OpenId Connect specification the authentication response should look similar like so:
HTTP/1.1 302 Found
Location: https://client.example.org/cb?
code=SplxlOBeZQQYbYS6WxSbIA
&state=af0ifjsldkj
The state is coming from the Client request, but the code is s secret key that I have to generate and store it by myself (Authorization Server) in a safe place. But wait, I need also to store all the AuthorizationRequest data after verifying the Client successfully, because I need this data when our Authorization Server issuing the Identity Token and Access Token to the client. So how can I store all these information.
Inside Model folder create a new class named AuthorizationCode.cs as follow:
using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Security.Principal;
namespace OAuth20.Server.Models
{
public class AuthorizationCode
{
public string ClientId { get; set; }
public string ClientSecret { get; set; }
public string RedirectUri { get; set; }
public DateTime CreationTime { get; set; } = DateTime.UtcNow;
public bool IsOpenId { get; set; }
public IList<string> RequestedScopes { get; set; }
public ClaimsPrincipal Subject { get; set; }
public string Nonce { get; set; }
}
}
The name of this class is coming from the generation code step, and I will store this information inside C# Concurrent Dictionary this Dictionary expect from me a key that should be unique for any data that I would like to store inside this Dictionary.
Inside Services folder create a new folder named CodeService and inside the CodeService folder create a new class named CodeStoreService.cs the content of this class as follow:
This service is not completed yet we will come back to it again
using Microsoft.AspNetCore.Authentication.Cookies;
using OAuth20.Server.Models;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
namespace OAuth20.Server.Services.CodeServce
{
public class CodeStoreService : ICodeStoreService
{
private readonly ConcurrentDictionary<string, AuthorizationCode> _codeIssued = new ConcurrentDictionary<string, AuthorizationCode>();
private readonly ClientStore _clientStore = new ClientStore();
// Here I genrate the code for authorization, and I will store it
// in the Concurrent Dictionary
public string GenerateAuthorizationCode(string clientId, IList<string> requestedScope)
{
var client = _clientStore.Clients.Where(x => x.ClientId == clientId).FirstOrDefault();
if(client != null)
{
var code = Guid.NewGuid().ToString();
var authoCode = new AuthorizationCode
{
ClientId = clientId,
RedirectUri = client.RedirectUri,
RequestedScopes = requestedScope,
};
// then store the code is the Concurrent Dictionary
_codeIssued[code] = authoCode;
return code;
}
return null;
}
public AuthorizationCode GetClientDataByCode(string key)
{
AuthorizationCode authorizationCode;
if (_codeIssued.TryGetValue(key, out authorizationCode))
{
return authorizationCode;
}
return null;
}
public AuthorizationCode RemoveClientDataByCode(string key)
{
AuthorizationCode authorizationCode;
_codeIssued.TryRemove(key, out authorizationCode);
return null;
}
}
}
This class has three methods one to store the client data, one to get the client data, and the last one to remove the client data from the Concurrent Dictionary
You can use Dictionary instead of Concurrent Dictionary but the Concurrent Dictionary is thread safe, if you want to use Dictionary be sure to lock the method by using C# lock keywork, to save yourself from the Concurrency Issue
Create an interface inside CodeService and name it ICodeStoreService.cs
using OAuth20.Server.Models;
using System.Collections.Generic;
namespace OAuth20.Server.Services.CodeServce
{
public interface ICodeStoreService
{
string GenerateAuthorizationCode(string clientId, IList<string> requestedScope);
AuthorizationCode GetClientDataByCode(string key);
AuthorizationCode RemoveClientDataByCode(string key);
}
}
Update the CodeStoreService.cs class and let it to inheritance from the that interface you created recently
public class CodeStoreService : ICodeStoreService
we need to register this interface inside the program.cs class
builder.Services.AddSingleton<ICodeStoreService, CodeStoreService>();
Inside Model folder create new class named CheckClientResult.cs as follow:
namespace OAuth20.Server.Models
{
public class CheckClientResult
{
public Client Client { get; set; }
/// <summary>
/// The clinet is found in my Clients Store
/// </summary>
public bool IsSuccess { get; set; }
public string Error { get; set; }
public string ErrorDescription { get; set; }
}
}
Coming bake to the Authorize endpoint we need a new service to take care of verifying the client and issuing a token and store all the information that we need inside the Concurrent Dictionary that created recently. Let's dive in
Inside Services folder create a new class named AuthorizeResultService.cs
I will go very slowly from here to the end, be patient.and I'm going to put all the methods I need to authenticate the user.
This service is not completed yet we will come back to it again
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
using Microsoft.IdentityModel.Tokens;
using NuGet.Common;
using OAuth20.Server.Common;
using OAuth20.Server.Models;
using OAuth20.Server.OauthRequest;
using OAuth20.Server.OauthResponse;
using OAuth20.Server.Services.CodeServce;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
using System.Text;
namespace OAuth20.Server.Services
{
public class AuthorizeResultService : IAuthorizeResultService
{
private static string keyAlg = "66007d41-6924-49f2-ac0c-e63c4b1a1730";
private readonly ClientStore _clientStore = new ClientStore();
private readonly ICodeStoreService _codeStoreService;
public AuthorizeResultService(ICodeStoreService codeStoreService)
{
_codeStoreService = codeStoreService;
}
public AuthorizeResponse AuthorizeRequest(IHttpContextAccessor httpContextAccessor, AuthorizationRequest authorizationRequest)
{
AuthorizeResponse response = new AuthorizeResponse();
if (httpContextAccessor == null)
{
response.Error = ErrorTypeEnum.ServerError.GetEnumDescription();
return response;
}
var client = VerifyClientById(authorizationRequest.client_id);
if (!client.IsSuccess)
{
response.Error = client.ErrorDescription;
return response;
}
if (string.IsNullOrEmpty(authorizationRequest.response_type) || authorizationRequest.response_type != "code")
{
response.Error = ErrorTypeEnum.InvalidRequest.GetEnumDescription();
response.ErrorDescription = "response_type is required or is not valid";
return response;
}
if (!authorizationRequest.redirect_uri.IsRedirectUriStartWithHttps() && !httpContextAccessor.HttpContext.Request.IsHttps)
{
response.Error = ErrorTypeEnum.InvalidRequest.GetEnumDescription();
response.ErrorDescription = "redirect_url is not secure, MUST be TLS";
return response;
}
// check the return url is match the one that in the client store
// check the scope in the client store with the
// one that is comming from the request MUST be matched at leaset one
var scopes = authorizationRequest.scope.Split(' ');
var clientScopes = from m in client.Client.AllowedScopes
where scopes.Contains(m)
select m;
if (!clientScopes.Any())
{
response.Error = ErrorTypeEnum.InValidScope.GetEnumDescription();
response.ErrorDescription = "scopes are invalids";
return response;
}
string nonce = httpContextAccessor.HttpContext.Request.Query["nonce"].ToString();
// Verify that a scope parameter is present and contains the openid scope value.
// (If no openid scope value is present,
// the request may still be a valid OAuth 2.0 request, but is not an OpenID Connect request.)
string code = _codeStoreService.GenerateAuthorizationCode(authorizationRequest.client_id, clientScopes.ToList());
if (code == null)
{
response.Error = ErrorTypeEnum.TemporarilyUnAvailable.GetEnumDescription();
return response;
}
response.RedirectUri = client.Client.RedirectUri + "?response_type=code" + "&state=" + authorizationRequest.state;
response.Code = code;
response.State = authorizationRequest.state;
response.RequestedScopes = clientScopes.ToList();
response.Nonce = nonce;
return response;
}
private CheckClientResult VerifyClientById(string clientId, bool checkWithSecret = false, string clientSecret = null)
{
CheckClientResult result = new CheckClientResult() { IsSuccess = false };
if (!string.IsNullOrWhiteSpace(clientId))
{
var client = _clientStore.Clients.Where(x => x.ClientId.Equals(clientId, StringComparison.OrdinalIgnoreCase)).FirstOrDefault();
if (client != null)
{
if (checkWithSecret && !string.IsNullOrEmpty(clientSecret))
{
bool hasSamesecretId = client.ClientSecret.Equals(clientSecret, StringComparison.InvariantCulture);
if (!hasSamesecretId)
{
result.Error = ErrorTypeEnum.InvalidClient.GetEnumDescription();
return result;
}
}
// check if client is enabled or not
if (client.IsActive)
{
result.IsSuccess = true;
result.Client = client;
return result;
}
else
{
result.ErrorDescription = ErrorTypeEnum.UnAuthoriazedClient.GetEnumDescription();
return result;
}
}
}
result.ErrorDescription = ErrorTypeEnum.AccessDenied.GetEnumDescription();
return result;
}
}
}
Inside Service folder create a new interface name IAuthorizeResultService.cs and the content of this interface as follow:
using Microsoft.AspNetCore.Http;
using OAuth20.Server.OauthRequest;
using OAuth20.Server.OauthResponse;
namespace OAuth20.Server.Services
{
public interface IAuthorizeResultService
{
AuthorizeResponse AuthorizeRequest(IHttpContextAccessor httpContextAccessor, AuthorizationRequest authorizationRequest);
}
}
we need to register this interface inside the program.cs class
builder.Services.AddScoped<IAuthorizeResultService, AuthorizeResultService>();
After the Client Authenticated successfully, we need away to let the Resource Owner to insert his username and password, so we need a new class named OpenIdConnectLoginRequest.cs created inside OauthRequest folder:
using System.Collections.Generic;
namespace OAuth20.Server.OauthRequest
{
public class OpenIdConnectLoginRequest
{
public string UserName { get; set; }
public string Password { get; set; }
public string RedirectUri { get; set; }
public string Code { get; set; }
public string Nonce { get; set; }
public IList<string> RequestedScopes { get; set; }
}
}
Now update your Authorize endpoint in HomeController.cs class to be like so:
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IAuthorizeResultService _authorizeResultService;
private readonly ICodeStoreService _codeStoreService;
public HomeController(IHttpContextAccessor httpContextAccessor, IAuthorizeResultService authorizeResultService,
ICodeStoreService codeStoreService)
{
_httpContextAccessor = httpContextAccessor;
_authorizeResultService = authorizeResultService;
_codeStoreService = codeStoreService;
}
public IActionResult Authorize(AuthorizationRequest authorizationRequest)
{
var result = _authorizeResultService.AuthorizeRequest(_httpContextAccessor, authorizationRequest);
if (result.HasError)
return RedirectToAction("Error", new { error = result.Error });
var loginModel = new OpenIdConnectLoginRequest
{
RedirectUri = result.RedirectUri,
Code = result.Code,
RequestedScopes = result.RequestedScopes,
Nonce = result.Nonce
};
return View("Login", loginModel);
}
When the AuthorizeRequest method returns a successful response, we returned the user to the Login view
Create a new Action Method inside HomeController.cs named Login
[HttpGet]
public IActionResult Login()
{
return View();
}
Inside Views/Home folder create a new Login.cshtml file
@model OAuth20.Server.OauthRequest.OpenIdConnectLoginRequest
@{
ViewData["Title"] = "Login";
Layout = "~/Views/Shared/_Layout.cshtml";
}
<h1>Login - Page</h1>
<div class="row">
@foreach (var i in Model.RequestedScopes)
{
<p>@i</p>
}
<div class="col-12">
<form asp-action="Login" asp-controller="Home" method="post">
<input type="hidden" asp-for="RedirectUri" />
<input type="hidden" asp-for="Code" />
<input type="hidden" asp-for="Nonce" />
@for (int i = 0; i < Model.RequestedScopes.Count; i++)
{
<input type="hidden" asp-for="RequestedScopes[i]" />
}
<div class="col-md-6">
<label>User Name</label>
<input type="text" asp-for="UserName" class="form-control" />
</div>
<div class="col-md-6">
<label>Password</label>
<input type="text" asp-for="Password" class="form-control" />
</div>
<div class="col-md-6">
<input type="submit" value="Login" class="btn btn-primary" />
</div>
</form>
</div>
</div>
Here the user will insert his username and password, but for that we have to create a POST version of the Login Action Method, so let us create it inside HomController.cs class
but before that we need to update the CodeStoreService.cs service by adding a new method to it, named UpdatedClientDataByCode so open the CodeStoreService.cs and add the follow mwthod, don't forget to update the ICodeStoreService interface as well by adding the name of the method
// TODO
// Before updated the Concurrent Dictionary I have to Process User Sign In,
// and check the user credienail first
// But here I merge this process here inside update Concurrent Dictionary method
public AuthorizationCode UpdatedClientDataByCode(string key, IList<string> requestdScopes,
string userName, string password = null, string nonce = null)
{
var oldValue = GetClientDataByCode(key);
if (oldValue != null)
{
// check the requested scopes with the one that are stored in the Client Store
var client = _clientStore.Clients.Where(x => x.ClientId == oldValue.ClientId).FirstOrDefault();
if (client != null)
{
var clientScope = (from m in client.AllowedScopes
where requestdScopes.Contains(m)
select m).ToList();
if (!clientScope.Any())
return null;
AuthorizationCode newValue = new AuthorizationCode
{
ClientId = oldValue.ClientId,
CreationTime = oldValue.CreationTime,
IsOpenId = requestdScopes.Contains("openId") || requestdScopes.Contains("profile"),
RedirectUri = oldValue.RedirectUri,
RequestedScopes = requestdScopes,
Nonce = nonce
};
// ------------------ I suppose the user name and password is correct -----------------
var claims = new List<Claim>();
if (newValue.IsOpenId)
{
// TODO
// Add more claims to the claims
}
var claimIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
newValue.Subject = new ClaimsPrincipal(claimIdentity);
// ------------------ ----------------------------------------------- -----------------
var result = _codeIssued.TryUpdate(key, newValue, oldValue);
if (result)
return newValue;
return null;
}
}
return null;
}
The purpose of this method is to update the HttpContext's Request information for the client in Concurrent Dictionary.
Now create the POST Action Method for the Login inside HomeController.cs
[HttpPost]
public async Task<IActionResult> Login(OpenIdConnectLoginRequest loginRequest)
{
// here I have to check if the username and passowrd is correct
// and I will show you how to integrate the ASP.NET Core Identity
// With our framework
var result = _codeStoreService.UpdatedClientDataByCode(loginRequest.Code, loginRequest.RequestedScopes,
loginRequest.UserName, nonce: loginRequest.Nonce);
if (result != null)
{
loginRequest.RedirectUri = loginRequest.RedirectUri + "&code=" + loginRequest.Code;
return Redirect(loginRequest.RedirectUri);
}
return RedirectToAction("Error", new { error = "invalid_request" });
}
Inside Views/Home create a new Error.cshtml file and paste the follow code inside it
@model string
@{
ViewData["Title"] = "Error";
Layout = "~/Views/Shared/_Layout.cshtml";
}
<h1>Error</h1>
After user login successfully we retune him/her to the return URI that we verify it by the one in the ClientStore.cs after this point the AddOpenIdConnect (From Client Application) extension make a soft HTTP call to token endpoint, and you remember where we defend the value of this endpoint, if you check the DiscoveryEndpointController.cs file you will find the value of this endpoint is assigned to the token_endpoint property as follow:
token_endpoint = "https://localhost:7275/Home/Token",
Ok, at this point we complete the process of the Authorization the next step is the TOKEN
Issuing Identity Token and AccessToken
We need new classes to make our journey very easy when we are working with these tokens (id_token & access_token)
Inside OauthRequest folder create a new class named TokenRequest.cs as follow:
namespace OAuth20.Server.OauthRequest
{
public class TokenRequest
{
public string ClientId { get; set; }
public string ClientSecret { get; set; }
public string Code { get; set; }
public string GrantType { get; set; }
public string RedirectUri { get; set; }
public string CodeVerifier { get; set; }
}
}
And then, inside Models folder create a new enum named TokenTypeEnum.cs
using System.ComponentModel;
namespace OAuth20.Server.Models
{
public enum TokenTypeEnum : byte
{
[Description("Bearer")]
Bearer
}
}
And last but not least inside OauthResponse folder create a new class named TokenResponse.cs as follow:
using OAuth20.Server.Common;
using OAuth20.Server.Models;
namespace OAuth20.Server.OauthResponse
{
public class TokenResponse
{
/// <summary>
/// Oauth 2
/// </summary>
public string access_token { get; set; }
/// <summary>
/// OpenId Connect
/// </summary>
public string id_token { get; set; }
/// <summary>
/// By default is Bearer
/// </summary>
public string token_type { get; set; } = TokenTypeEnum.Bearer.GetEnumDescription();
/// <summary>
/// Authorization Code. This is always returned when using the Hybrid Flow.
/// </summary>
public string code { get; set; }
// For Error Details if any
public string Error { get; set; } = string.Empty;
public string ErrorUri { get; set; }
public string ErrorDescription { get; set; }
public bool HasError => !string.IsNullOrEmpty(Error);
}
}
Updating the AuthorizeResultService.cs class by adding the following new method:
public TokenResponse GenerateToken(IHttpContextAccessor httpContextAccessor)
{
TokenRequest request = new TokenRequest();
request.CodeVerifier = httpContextAccessor.HttpContext.Request.Form["code_verifier"];
request.ClientId = httpContextAccessor.HttpContext.Request.Form["client_id"];
request.ClientSecret = httpContextAccessor.HttpContext.Request.Form["client_secret"];
request.Code = httpContextAccessor.HttpContext.Request.Form["code"];
request.GrantType = httpContextAccessor.HttpContext.Request.Form["grant_type"];
request.RedirectUri = httpContextAccessor.HttpContext.Request.Form["redirect_uri"];
var checkClientResult = this.VerifyClientById(request.ClientId, true, request.ClientSecret);
if (!checkClientResult.IsSuccess)
{
return new TokenResponse { Error = checkClientResult.Error, ErrorDescription = checkClientResult.ErrorDescription };
}
// check code from the Concurrent Dictionary
var clientCodeChecker = _codeStoreService.GetClientDataByCode(request.Code);
if (clientCodeChecker == null)
return new TokenResponse { Error = ErrorTypeEnum.InvalidGrant.GetEnumDescription() };
// check if the current client who is one made this authentication request
if (request.ClientId != clientCodeChecker.ClientId)
return new TokenResponse { Error = ErrorTypeEnum.InvalidGrant.GetEnumDescription() };
// TODO:
// also I have to check the rediret uri
// Now here I will Issue the Id_token
JwtSecurityToken id_token = null;
if (clientCodeChecker.IsOpenId)
{
// Generate Identity Token
int iat = (int)DateTime.UtcNow.Subtract(new DateTime(1970, 1, 1)).TotalSeconds;
string[] amrs = new string[] { "pwd" };
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(keyAlg));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new List<Claim>()
{
new Claim("sub", "856933325856"),
new Claim("given_name", "Mohammed Ahmed Hussien"),
new Claim("iat", iat.ToString(), ClaimValueTypes.Integer), // time stamp
new Claim("nonce", clientCodeChecker.Nonce)
};
foreach (var amr in amrs)
claims.Add(new Claim("amr", amr));// authentication method reference
id_token = new JwtSecurityToken("https://localhost:7275", request.ClientId, claims, signingCredentials: credentials,
expires: DateTime.UtcNow.AddMinutes(
int.Parse("5")));
}
// Here I have to generate access token
var key_at = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(keyAlg));
var credentials_at = new SigningCredentials(key_at, SecurityAlgorithms.HmacSha256);
var claims_at = new List<Claim>();
var access_token = new JwtSecurityToken("https://localhost:7275", request.ClientId, claims_at, signingCredentials: credentials_at,
expires: DateTime.UtcNow.AddMinutes(
int.Parse("5")));
// here remoce the code from the Concurrent Dictionary
_codeStoreService.RemoveClientDataByCode(request.Code);
return new TokenResponse
{
access_token = new JwtSecurityTokenHandler().WriteToken(access_token),
id_token = id_token != null ? new JwtSecurityTokenHandler().WriteToken(id_token) : null,
code = request.Code
};
}
The method is straightforward, verify the Client and then create a new id_token and then access_token
Be sure to add the signature this method to the IAuthorizeResultService.cs interface
Back to HomeController.cs class here is the signature of the token endoint
public JsonResult Token()
{
var result = _authorizeResultService.GenerateToken(_httpContextAccessor);
if (result.HasError)
return Json("0");
return Json(result);
}
Back to our Client Application that we create at the beginning of this post. Inside Views/Home create a new Index.cshtml file and the content of this file as follow:
@using Microsoft.AspNetCore.Authentication
@{
ViewData["Title"] = "Index";
Layout = "~/Views/_Layout.cshtml";
}
<div class="col-12">
<div class="card">
<div class="card-header">
<h1>Tokens Resut - For PlatformNet6 Client</h1>
</div>
<div class="card-body">
@if (User.Identity.IsAuthenticated)
{
<h2>User is Authenticated</h2>
<p>
<ul>
@foreach (var claim in User.Claims)
{
<li><strong> @claim.Type:</strong> @claim.Value</li>
}
<li><strong>Access Token: </strong>@await Context.GetTokenAsync("access_token")</li>
<li><strong>Identity Token: </strong>@await Context.GetTokenAsync("id_token")</li>
</ul>
</p>
}
else
{
<h2>ohhhh, u being Unauthenticated</h2>
}
</div>
</div>
</div>
Now run your Authorization Server and then you Client Application
And you will see the result like so:
Ooooooops we do it, now we need to improve our Authorization Server code and application structure. This is the next step. see you in the next post.
Again, you can download the completed source code from my Github repos from here feel free to add pull request.
Enjoy!
Top comments (48)
Hi, could I ask for some advice? I'm starting with OAuth and I downloaded your server project and I'm trying to connect a test (client) application to it. After starting the OAuth server and the Client, the login screen appears correctly if I am not logged in. After logging in, it redirects to the url where I have the client application (sample: https://localhost:7285/signin-oidc?response_type=code&state=CfDJ8Ecxanf86J1JnZZYH7c8PDzfq9Skgyu5iPZeW31rddOYS-I9c2n8jR...), but this error SecurityTokenException: Unable to validate is displayed the 'id_token', no suitable ISecurityTokenValidator was found for: ''.".
If I edit the url (localhost:7285/), the application is logged in.
I couldn't get to the root cause. Can I ask you for some advice?
Thank you very much Lubos
Hi, sure, can you show me the configuration that you have been make at the client level.
Be sure to remove these lines of codes from the client application.
Because the source code in the GitHub repo now is support the key validator
Hi, thank you very much for your response. I am sending a link to the entire project, where I try and test it
dcfreenet.info/0_test/TestOpenAuth...
My goal will be client applications in Bl@zor
And thank you for adding to your answer. I will try what you wrote.
Hi, I already tried to remove options.TokenValidationParameters from the client according to your advice... but the result is still the same. Both in the Bl@zor project and in the MVC project. I'm sending a command.
So I tried to remove the given code but the result was the same :-(
Let me check your source code
Thanks for the reply, so I'm sending the link to github.
github.com/Svetelak/OAuthServerTes...
Sorry for late to response, yeah there is a bug, the bug is that the user is not logged-in and in the GenerateToken method like so
I will fix that this weekend.
Super thank you very much. As soon as it's ok, I'll test :-)
Thank you so much
Hi, the bug is fixed, you can try now from your side.
I try it with Balzor app it's working.
this is the fixed commit:
github.com/Shoogn/OAuth20Server/co...
I tried it and it works! Thank you very much, perfect job
Hello, just following the first steps of the tutorial.
when i start the two applications the ~/.well-known/openid-configuration endpoint it's not fired. Could it be because I'm working on an api project and not mvc? thanks!
This is the starup config of the client (registered in my oauth server project)
SOLVED
I forgot to decorate the Client controller with the
[Authorize]
attribute!Hi, I tried to change the RSA xml to a new one generated by:
When I try to login with my client I get "Signature validation failed. Token does not have a kid. Keys tried: 'System.Text.StringBuilder'".
Should I change anything else, or the generated key is wrong? The original works fine.
Found it. I had to replace "n" and "kid" values in jwks.json under wwwroot folder to the Modulus of the new RSA XML.
Glad to here that, and if you want to change the entire directory, you can navigate to the DiscoveryEndpointController object there is an Action Method named Jwk you will find the current patch assigned to wwwroot folder from there you can provide your preferred path
I'm testing out your Authorization Server from github and I can successfully login through it, which either resulted in creating a new account or log in to at my side. However when I tried to link exiting account on my side to your Authorization Server (via myserver/Identity/Account/Manage/E...), I'm getting the following exception on the Authorization Server,
If I ignore the exception, the Authorization Server's login page will be shown and upon entering the credential, the exception occurs again. On my side, the debug log shows,
Any advice?
Issue resolved. We cannot use the same hostname for both the client and the server even when the ports are different. I tried with different hostnames and it works.
I'm very glad you know about this point, and you fix the problem
Thanks for the post. I'm need to have the similar solution and I'm using your example in our case. I have created two different domains client and server on the windows server 2019. Test is successfully authenticating the request but while calling back the following URL getting error page. Anyone can help?
Following is the callback URL
https://<client domain>/signin-oidc?response_type=code&state=CfDJ8ECkv9oAtGxNm2CqZXor7SUGMwvQj3_9ANUG193arlR7-OOacaFoYMP50c6IdSD7YdSfHHQgOIeLbrBDuDgdW60l1Lm8Ulcu6w6nI0_QOcqi-OR_v-wCXFw44RUJG0xibVecc06tB2QXQjlrACUd8aGG61eQJCs0GBnAItLP4Zn06tMb1M1n3jPXXagHh2QX7Bic2M4BgFqkRcoWxOyfDX2-9Lhht4MxIi-xRZ3w8dDUy0F2QiNvz-bakvWd3vfjEge66sUWXG-T-MkS8zep7ralR3rUstMqoxwgz7NOvJGbkkpi6fYL5F0amhYdstrC4_4npohGgEzUiKSxqsAPi6Key0D9uKOQnDl7tpHoLRPG5AXBsDdID11QUGVqIZPVmQ&code=fsYat7bISln_lWnKYxrP-4gAHcxs7U_9tq1fArrIWLA
Hi, you can not call this url direc because the auth server generate the code foe one time and also the client generate the state for one time as well.
Hi,
Many thanks for sharing your application. I have executed both (client & server) applications successfully but when I deployed on the server. it shown shared error while return from server. Please help me, I am struck.
Please response urgently
@mohammedahmed Please guide about it
Can you share the Auth Server App and Client app through github link
Hello Sir, what an amazinf post and interesting contribution. Thanks for that. is it possible if you can update this article to align with latest github code. so, a beginner would have more in-depth understanding about all componennts and will have easy to implement code.
Would then allow me to use SSO with the Authorization Server ? Where If I Log In With ClientID 2 I can then go to the homecontroller for lets say ClientID 3 and it will know I already logged in with ClientID 2 ?
@mohammedahmed , I see that you are using a local SQL server database. You didn't provide the db, right?
Yeah, you can run the migration script
Update-Database
How to change InMemory to a database the OAuthOption
I am also curious about this. Where can one find this script? Did not come down with the git clone...
Where is the Update-Database migration script located ?
How can I implement the cliente credentials flow?
It's very easy, all you have to do is to call the token endpoint directly.
Hey, now it's implemented, give it a try!
Oh, nice, thanks