Skip to content

Commit af2b0f1

Browse files
committed
AspNetCore OAuth sample
This provides an OAuth sample implementation for Asp.Net Core. This is my first attempt at writing my own OAuth provider for Asp.Net Core and I'm also fairly new to OAuth in general. So please let me know if there is anything that can be improved in this sample closes microsoft#45
1 parent 2f31a70 commit af2b0f1

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

57 files changed

+40403
-2
lines changed
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
using Microsoft.AspNetCore.Authentication;
2+
using Microsoft.AspNetCore.Authentication.OAuth;
3+
using Microsoft.AspNetCore.Server.IIS.Core;
4+
using Microsoft.Extensions.DependencyInjection;
5+
using Microsoft.Extensions.Logging;
6+
using Microsoft.Extensions.Options;
7+
using System;
8+
using System.Collections.Generic;
9+
using System.Linq;
10+
using System.Net.Http;
11+
using System.Net.Http.Headers;
12+
using System.Security.Claims;
13+
using System.Text.Encodings.Web;
14+
using System.Text.Json;
15+
using System.Threading.Tasks;
16+
17+
namespace NetCoreOAuthWebSample
18+
{
19+
public class AzureDevOpsOAuthOptions : OAuthOptions
20+
{
21+
public AzureDevOpsOAuthOptions()
22+
{
23+
ClaimsIssuer = AzureDevOpsAuthenticationDefaults.Issuer;
24+
CallbackPath = AzureDevOpsAuthenticationDefaults.CallbackPath;
25+
AuthorizationEndpoint = AzureDevOpsAuthenticationDefaults.AuthorizationEndPoint;
26+
TokenEndpoint = AzureDevOpsAuthenticationDefaults.TokenEndPoint;
27+
UserInformationEndpoint = AzureDevOpsAuthenticationDefaults.UserInformationEndPoint;
28+
29+
ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "id");
30+
ClaimActions.MapJsonKey(ClaimTypes.Email, "emailAddress");
31+
ClaimActions.MapJsonKey(ClaimTypes.Name, "displayName");
32+
}
33+
}
34+
35+
public static class AzureDevOpsAuthenticationDefaults
36+
{
37+
public const string AuthenticationScheme = "AzureDevOps";
38+
public const string Display = "AzureDevOps";
39+
public const string Issuer = "AzureDevOps";
40+
public const string CallbackPath = "/signin-azdo";
41+
public const string AuthorizationEndPoint = "https://app.vssps.visualstudio.com/oauth2/authorize";
42+
public const string TokenEndPoint = "https://app.vssps.visualstudio.com/oauth2/token";
43+
public const string UserInformationEndPoint = "https://app.vssps.visualstudio.com/_apis/profile/profiles/me";
44+
}
45+
46+
public static class AzureDevOpsExtensions
47+
{
48+
public static AuthenticationBuilder AddAzureDevOps(this AuthenticationBuilder builder, Action<AzureDevOpsOAuthOptions> configuration) =>
49+
builder.AddOAuth<AzureDevOpsOAuthOptions, AzureDevOpsAuthenticationHandler>(
50+
AzureDevOpsAuthenticationDefaults.AuthenticationScheme,
51+
AzureDevOpsAuthenticationDefaults.Display,
52+
configuration);
53+
}
54+
55+
public class AzureDevOpsAuthenticationHandler : OAuthHandler<AzureDevOpsOAuthOptions>
56+
{
57+
public AzureDevOpsAuthenticationHandler(
58+
IOptionsMonitor<AzureDevOpsOAuthOptions> options,
59+
ILoggerFactory logger,
60+
UrlEncoder encoder,
61+
ISystemClock clock)
62+
: base(options, logger, encoder, clock)
63+
{
64+
65+
}
66+
67+
protected override async Task<AuthenticationTicket> CreateTicketAsync(
68+
ClaimsIdentity identity,
69+
AuthenticationProperties properties,
70+
OAuthTokenResponse tokens)
71+
{
72+
using var request = new HttpRequestMessage(HttpMethod.Get, Options.UserInformationEndpoint);
73+
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
74+
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
75+
76+
using var response = await Backchannel.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Context.RequestAborted);
77+
if (!response.IsSuccessStatusCode)
78+
{
79+
Logger.LogError("An error occurred while retrieving the user profile: the remote server " +
80+
"returned a {Status} response with the following payload: {Headers} {Body}.",
81+
response.StatusCode,
82+
response.Headers.ToString(),
83+
await response.Content.ReadAsStringAsync());
84+
85+
throw new HttpRequestException("An error occurred while retrieving the user profile.");
86+
}
87+
88+
using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
89+
var principal = new ClaimsPrincipal(identity);
90+
var context = new OAuthCreatingTicketContext(principal, properties, Context, Scheme, Options, Backchannel, tokens, payload.RootElement);
91+
context.RunClaimActions();
92+
93+
await Options.Events.CreatingTicket(context);
94+
return new AuthenticationTicket(context.Principal, context.Properties, Scheme.Name);
95+
}
96+
97+
protected override async Task<OAuthTokenResponse> ExchangeCodeAsync(OAuthCodeExchangeContext context)
98+
{
99+
using var request = new HttpRequestMessage(HttpMethod.Post, Options.TokenEndpoint);
100+
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
101+
102+
request.Content = new FormUrlEncodedContent(new Dictionary<string, string>
103+
{
104+
["redirect_uri"] = context.RedirectUri,
105+
["client_assertion"] = Options.ClientSecret,
106+
["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
107+
["assertion"] = context.Code,
108+
["grant_type"] = "urn:ietf:params:oauth:grant-type:jwt-bearer",
109+
});
110+
111+
using var response = await Backchannel.SendAsync(request, Context.RequestAborted);
112+
113+
if (!response.IsSuccessStatusCode)
114+
{
115+
Logger.LogError("An error occurred while retrieving an access token: the remote server " +
116+
"returned a {Status} response with the following payload: {Headers} {Body}.",
117+
response.StatusCode,
118+
response.Headers.ToString(),
119+
await response.Content.ReadAsStringAsync());
120+
121+
return OAuthTokenResponse.Failed(new Exception("An error occurred while retrieving an access token."));
122+
}
123+
124+
var content = await response.Content.ReadAsStringAsync();
125+
var payload = JsonDocument.Parse(content);
126+
127+
return OAuthTokenResponse.Success(payload);
128+
}
129+
}
130+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using System.Threading.Tasks;
2+
using Microsoft.AspNetCore.Authentication;
3+
using Microsoft.AspNetCore.Authentication.Cookies;
4+
using Microsoft.AspNetCore.Mvc;
5+
6+
namespace NetCoreOAuthWebSample.Controllers
7+
{
8+
public class AuthenticationController : Controller
9+
{
10+
[HttpPost("~/signin")]
11+
public IActionResult SignIn([FromForm] string provider) =>
12+
Challenge(new AuthenticationProperties { RedirectUri = "/" });
13+
14+
[HttpPost("~/signout")]
15+
public IActionResult SignOut() =>
16+
SignOut(new AuthenticationProperties { RedirectUri = "/" },
17+
CookieAuthenticationDefaults.AuthenticationScheme);
18+
}
19+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<Project Sdk="Microsoft.NET.Sdk.Web">
2+
3+
<PropertyGroup>
4+
<TargetFramework>netcoreapp3.1</TargetFramework>
5+
<UserSecretsId>b1e36ef6-1e2f-41bc-a349-e2cf05a9e820</UserSecretsId>
6+
</PropertyGroup>
7+
8+
</Project>
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
@page
2+
@model ErrorModel
3+
@{
4+
ViewData["Title"] = "Error";
5+
}
6+
7+
<h1 class="text-danger">Error.</h1>
8+
<h2 class="text-danger">An error occurred while processing your request.</h2>
9+
10+
@if (Model.ShowRequestId)
11+
{
12+
<p>
13+
<strong>Request ID:</strong> <code>@Model.RequestId</code>
14+
</p>
15+
}
16+
17+
<h3>Development Mode</h3>
18+
<p>
19+
Swapping to the <strong>Development</strong> environment displays detailed information about the error that occurred.
20+
</p>
21+
<p>
22+
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
23+
It can result in displaying sensitive information from exceptions to end users.
24+
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
25+
and restarting the app.
26+
</p>
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Diagnostics;
4+
using System.Linq;
5+
using System.Threading.Tasks;
6+
using Microsoft.AspNetCore.Mvc;
7+
using Microsoft.AspNetCore.Mvc.RazorPages;
8+
using Microsoft.Extensions.Logging;
9+
10+
namespace NetCoreOAuthWebSample.Pages
11+
{
12+
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
13+
public class ErrorModel : PageModel
14+
{
15+
public string RequestId { get; set; }
16+
17+
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
18+
19+
private readonly ILogger<ErrorModel> _logger;
20+
21+
public ErrorModel(ILogger<ErrorModel> logger)
22+
{
23+
_logger = logger;
24+
}
25+
26+
public void OnGet()
27+
{
28+
RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
29+
}
30+
}
31+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
@page
2+
@model IndexModel
3+
@{
4+
ViewData["Title"] = "Home page";
5+
}
6+
7+
@if (User?.Identity?.IsAuthenticated ?? false)
8+
{
9+
<h1>Welcome, @User.Identity.Name</h1>
10+
11+
<p>
12+
@foreach (var claim in @Model.HttpContext.User.Claims)
13+
{
14+
<div><code>@claim.Type</code>: <strong>@claim.Value</strong></div>
15+
}
16+
</p>
17+
<form action="/signout" method="post">
18+
<button class="btn btn-lg btn-success m-1" type="submit">Sign Out</button>
19+
</form>
20+
}
21+
else
22+
{
23+
<h1>Welcome, anonymous</h1>
24+
<form action="/signin" method="post">
25+
<button class="btn btn-lg btn-success m-1" type="submit">Sign In</button>
26+
</form>
27+
}
28+
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Threading.Tasks;
5+
using Microsoft.AspNetCore.Mvc;
6+
using Microsoft.AspNetCore.Mvc.RazorPages;
7+
using Microsoft.Extensions.Logging;
8+
9+
namespace NetCoreOAuthWebSample.Pages
10+
{
11+
public class IndexModel : PageModel
12+
{
13+
private readonly ILogger<IndexModel> _logger;
14+
15+
public IndexModel(ILogger<IndexModel> logger)
16+
{
17+
_logger = logger;
18+
}
19+
20+
public void OnGet()
21+
{
22+
23+
}
24+
}
25+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
@page
2+
@model PrivacyModel
3+
@{
4+
ViewData["Title"] = "Privacy Policy";
5+
}
6+
<h1>@ViewData["Title"]</h1>
7+
8+
<p>Use this page to detail your site's privacy policy.</p>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Threading.Tasks;
5+
using Microsoft.AspNetCore.Mvc;
6+
using Microsoft.AspNetCore.Mvc.RazorPages;
7+
using Microsoft.Extensions.Logging;
8+
9+
namespace NetCoreOAuthWebSample.Pages
10+
{
11+
public class PrivacyModel : PageModel
12+
{
13+
private readonly ILogger<PrivacyModel> _logger;
14+
15+
public PrivacyModel(ILogger<PrivacyModel> logger)
16+
{
17+
_logger = logger;
18+
}
19+
20+
public void OnGet()
21+
{
22+
}
23+
}
24+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>@ViewData["Title"] - NetCoreOAuthWebSample</title>
7+
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
8+
<link rel="stylesheet" href="~/css/site.css" />
9+
</head>
10+
<body>
11+
<header>
12+
<nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3">
13+
<div class="container">
14+
<a class="navbar-brand" asp-area="" asp-page="/Index">NetCoreOAuthWebSample</a>
15+
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target=".navbar-collapse" aria-controls="navbarSupportedContent"
16+
aria-expanded="false" aria-label="Toggle navigation">
17+
<span class="navbar-toggler-icon"></span>
18+
</button>
19+
<div class="navbar-collapse collapse d-sm-inline-flex flex-sm-row-reverse">
20+
<ul class="navbar-nav flex-grow-1">
21+
<li class="nav-item">
22+
<a class="nav-link text-dark" asp-area="" asp-page="/Index">Home</a>
23+
</li>
24+
<li class="nav-item">
25+
<a class="nav-link text-dark" asp-area="" asp-page="/Privacy">Privacy</a>
26+
</li>
27+
</ul>
28+
</div>
29+
</div>
30+
</nav>
31+
</header>
32+
<div class="container">
33+
<main role="main" class="pb-3">
34+
@RenderBody()
35+
</main>
36+
</div>
37+
38+
<footer class="border-top footer text-muted">
39+
<div class="container">
40+
&copy; 2020 - NetCoreOAuthWebSample - <a asp-area="" asp-page="/Privacy">Privacy</a>
41+
</div>
42+
</footer>
43+
44+
<script src="~/lib/jquery/dist/jquery.min.js"></script>
45+
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
46+
<script src="~/js/site.js" asp-append-version="true"></script>
47+
48+
@await RenderSectionAsync("Scripts", required: false)
49+
</body>
50+
</html>
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
2+
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
@using NetCoreOAuthWebSample
2+
@namespace NetCoreOAuthWebSample.Pages
3+
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
@{
2+
Layout = "_Layout";
3+
}

NetCoreOAuthWebSample/Program.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Threading.Tasks;
5+
using Microsoft.AspNetCore.Hosting;
6+
using Microsoft.Extensions.Configuration;
7+
using Microsoft.Extensions.Hosting;
8+
using Microsoft.Extensions.Logging;
9+
10+
namespace NetCoreOAuthWebSample
11+
{
12+
public class Program
13+
{
14+
public static void Main(string[] args)
15+
{
16+
CreateHostBuilder(args).Build().Run();
17+
}
18+
19+
public static IHostBuilder CreateHostBuilder(string[] args) =>
20+
Host.CreateDefaultBuilder(args)
21+
.ConfigureWebHostDefaults(webBuilder =>
22+
{
23+
webBuilder.UseStartup<Startup>();
24+
});
25+
}
26+
}

0 commit comments

Comments
 (0)