OAuth 2
There is a central Authorization Server (AS)/Identity Provider (IDP). This may be an internal server (eg ADFS), or an eternal one (Azure). It manages resources and scopes, and publishes a public discovery metadata (/.well-known/openid-configuration). This includes the public key, urls to get tokens, scopes etc.
The IDP issues JWT (jason web token/"jot") tokens. This is a base64 encoded string (not secure from reading), but includes a footer section which is signed by the issuer (verify with the public key) so it is tamper-proof. This token can be passed as a bearer token between applications/browser-api in the http Authorization header (Authorization: Bearer xxxx).
The terminology for the api/application is confusing. Resource server is the API with protected resources. Resource owner is the client/application using it.
Microsoft (Azure AD) extends OAuth with audience (rather than just scope) and mapInboundClaims (renaming claims), which can be confusing.
Tokens
Access tokens are like cookies in traditional forms authentication:
- There are passed in the HTTP header Authentication: Bearer xxxx
- The format is a base64 encoded JWT (json web token). It's not encrypted or signed- you must use SSL
- You can refresh them: grant_type=refresh_token. Which can update the information.
OpenId (with scope "openid" plus "profile" and others) adds an id_token in addition to an access (bearer) token with user claims, which is put into the cookie.
With scope "offline_access" we can get a refresh_token, which is effectively a keep-alive for a token.
Flows
As of OAuth 2.1 there are only 2 recommended flows (others such as Password and Implicit flow are obsolete)
- Machine to machine: client credentials
- User/browser to server/api: authorization code (includes OpenId)
- For SPAs like Angular you can use a js lib like msal, which handles the token
- This is no longer recommended; instead use BFF (back-end for front-end) pattern with an app server that does the security and creates a same-site cookie which is used for apis. The browser only uses the local apis, which on the server can proxy out to "external" apis without exposing tokens.
- You can use a reverse proxy (eg yarp) in front of the static and api websites
- In Blazor, the BFF project can reference the blazor wasm, and includes app.MapFallbackToFile("index.html");
Generally you have to register the client with the auth server (so it knows where to redirect, or at least validate your redirect_uri).
Authorization code (Browsers/Apps)
- Authorization (response_type=code): browser-based redirect to auth server, which logs in/shows consent screen.
- → https://server/auth?client_id=1&response_type=code
The query-string has a client_id and may have a redirect_uri and scope (e.g. read, update...). - ← https://client/callback#token=xxx
The consent screen redirects back with the token in the url hash: location.hash.substr(1) - Best practice is to do another ajax call to validate the token (which returns json with the audience, which must match your client)
- Then send ajax calls for data with http header "Authorization: Bearer xxx"
- → https://server/auth?client_id=1&response_type=code
Authorization Code Flow (Server-side)
- Authorization Code (response_type=code): for server-side authentication.
- → https://server/auth?response_type=code&client_id=x
User redirected to auth server login page. When they log in/consent... - ← https://client/?code=xxx
The auth server redirects with a ?code=xxx to the client. - → POST https://server/grant_type=authorization_code&code=xxx&
client_id=CLIENT_ID&client_secret=CLIENT_SECRET
The client POSTs to a token endpoint with grant_type=authorization_code
(this is a "back channel" call from the server, the user can never see your client secret or the access token) - ← { "access_token":"hm4s8..." }
The auth server replies with the access_token in json.
- → https://server/auth?response_type=code&client_id=x
Client Credential (application-level access)
- → POST https://server/?grant_type=client_credentials&client_id=CLIENT_ID&client_secret=CLIENT_SECRET
Intended for app access (get list of users etc)
OpenID Connect
OpenID Connect is an authentication protocol built on top of OAuth2.
- install-package Microsoft.AspNetCore.Authentication.JwtBearer
In Startup do the following:
- Add UseCookieAuthentication (first!)
- Add UseOpenIdConnectAuthentication
- Add the IdP url ("Authority") and the Client's ID and RedirectUri
- In Scope, add the claims you'll need (otherwise you just get openid claims).
- In Notifications, the SecurityTokenValidated event allows for claims transformation (standardise the claim names e.g. Ws-fed like Uris or short names).
app.UseCookieAuthentication(new CookieAuthenticationOptions
{
AuthenticationType = "Cookies",
});
app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
{
Authority = "https://localhost:44324/core", //IdP Url,
ClientId = "tst-infrabel.be", //OpenID server must have this client
Scope = "openid profile roles", //what roles I want (default is openid)
RedirectUri = "https://localhost:44348/", //start page for this application
ResponseType = "code id_token token",
SignInAsAuthenticationType = "Cookies",
Notifications = new OpenIdConnectAuthenticationNotifications
{
AuthenticationFailed = context =>
{
//logging errors
Console.WriteLine(context.Exception);
return Task.FromResult(0);
},
//claims transformation
SecurityTokenValidated = notification =>
{
var id = notification.AuthenticationTicket.Identity;
// create new identity and set name and role claim type
var nid = new ClaimsIdentity(id.AuthenticationType);
//copy the claims in
nid.AddClaim(id.FindFirst(ClaimTypes.Name));
nid.AddClaims(id.FindAll(ClaimTypes.Role));
// add new claim
nid.AddClaim(new Claim("LogonTime", DateTime.UtcNow.ToString("u")));
notification.AuthenticationTicket =
new AuthenticationTicket(nid, notification.AuthenticationTicket.Properties);
return Task.FromResult(0);
}
}
});
The client must use SSL. If you don't (e.g. local development), the client will enter an infinite redirect loop back to the IdP (you can also have this if the client URL doesn't end in "/").
In app.UseCookieAuthentication(new CookieAuthenticationOptions
add:
CookieSecure = CookieSecureOption.Never
Obviously don't leave this is production- use the [RequireHttps]
attribute.
SignOut
Use Request.GetOwinContext().Authentication.SignOut();
Client
A simple console client
static void Main(string[] args)
{
var token = GetToken().Result;
Console.WriteLine(token);
var data = CallService(token).Result;
Console.WriteLine(data);
}
private async static Task<string> GetToken()
{
using (var client = new HttpClient())
{
var post =
new Dictionary<string, string>
{
{"grant_type", "password"},
{"username", "alice"},
{"password", "secret"},
//client
{"client_id", "1"},
{"client_secret", "secret"},
};
var response = await client.PostAsync("http://localhost:4746/token",
new FormUrlEncodedContent(post));
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
var json = JObject.Parse(content);
return json["access_token"].ToString();
}
throw new InvalidOperationException(response.ReasonPhrase);
}
}
private async static Task<string> CallService(string token)
{
using (var client = new HttpClient())
{
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
return await client.GetStringAsync("http://localhost:4746/api/data");
}
}