OpenID Connect is an identity layer that works over the top of OAuth 2.0.
Why OIDC over plain OAuth 2.0?
- OAuth is focused on authorisation, not authentication. Using OAuth for authentication is like giving someone a valet key to your house. The webapp assumes who you are based on the house key you bring. TWo issues, 1 you haven’t explicitly authenticated that webappX is to be granted access to your house key, and 2 webappX now has freaking access to your house key.
- OAuth is un-opinionated, resulting in many implementation nuances.
- OIDC standardises more security features, like token signing and verification, and dynamic registration.
Flows
Implicit
For frontend (such as browser based SPA apps) authentication.
- React app sends an authentication request to the OpenID provider, passing
client_id
- The user is authenticated.
- Details about user are encoded and signed into an
id_token
. This is passed back to the preconfigured redirect URI. - The React reads the
id_token
and verifies the signature using public key from http://localhost:9090/jwks
Authentication
For backend (non-browser) based authentication. Very similar to traditional OAuth.
- node.js webapp sends an authentication request to the OpenID provider, passing
client_id
- The user is authenticated.
- A one-time code is passed back to the preconfigured redirect URI.
- The backend node.js webapp send a HTTP request that includes the one-time code, client_id and client_secret to the Token Endpoint. If successful, a one-hour access_token and id_token are returned.
- The node.js app is now free to use the
access_token
to query the UserInfo Endpoint.
Scopes
The personal information (e.g. name, friends list, dob) a user is willing to allow to be passed along to the app. In OIDC a special scope called openid
needs to be passed to the request to identity provider, to flag OIDC is being used.
Local setup
Next I setup an actual OIDC server to experiment with. Using the node based oidc-provider
, created an OIDC server configured to do implicit flow, see ./node-oidc-provider/server.js
.
The demo.identityserver.io is another great option.
Throw a GET at http://localhost:9090/.well-known/openid-configuration, a standard OIDC endpoint to enumerate various meta (the specific endpoints and capabilities) about this particular OIDC service:
{
"authorization_endpoint": "http://localhost:9090/auth",
"claims_parameter_supported": false,
"claims_supported": ["sub", "sid", "auth_time", "iss"],
"grant_types_supported": [
"implicit",
"authorization_code",
"refresh_token"
],
"id_token_signing_alg_values_supported": ["HS256", "RS256"],
"issuer": "http://localhost:9090",
"jwks_uri": "http://localhost:9090/certs",
"request_object_signing_alg_values_supported": [
"HS256",
"RS256",
"PS256",
"ES256"
],
"request_parameter_supported": false,
"request_uri_parameter_supported": true,
"require_request_uri_registration": true,
"response_modes_supported": ["form_post", "fragment", "query"],
"response_types_supported": [
"code id_token token",
"code id_token",
"code token",
"code",
"id_token token",
"id_token",
"none"
],
"scopes_supported": ["openid", "offline_access"],
"subject_types_supported": ["public"],
"token_endpoint": "http://localhost:9090/token",
"token_endpoint_auth_methods_supported": [
"none",
"client_secret_basic",
"client_secret_jwt",
"client_secret_post",
"private_key_jwt"
],
"token_endpoint_auth_signing_alg_values_supported": [
"HS256",
"RS256",
"PS256",
"ES256"
],
"userinfo_endpoint": "http://localhost:9090/me",
"userinfo_signing_alg_values_supported": ["HS256", "RS256"],
"code_challenge_methods_supported": ["S256"],
"revocation_endpoint": "http://localhost:9090/token/revocation",
"revocation_endpoint_auth_methods_supported": [
"none",
"client_secret_basic",
"client_secret_jwt",
"client_secret_post",
"private_key_jwt"
],
"revocation_endpoint_auth_signing_alg_values_supported": [
"HS256",
"RS256",
"PS256",
"ES256"
],
"claim_types_supported": ["normal"]
}
For example, can see these endpoints being advertised:
"jwks_uri": "http://localhost:9090/certs",
"authorization_endpoint": "http://localhost:9090/auth"
"token_endpoint": "http://localhost:9090/token"
"userinfo_endpoint": "http://localhost:9090/me"
"revocation_endpoint": "http://localhost:9090/token/revocation"
Digital signature public key
The endpoint for validating JWT signatures is defined by jwks_uri
, which returns:
{
"keys": [
{
"kty": "RSA",
"kid": "keystore-CHANGE-ME",
"use": "sig",
"alg": "RS256",
"e": "AQAB",
"n": "xwQ72P9z9OYshiQ-ntDYaPnnfwG6u9JAdLMZ5o0dmjlcyrvwQRdoFIKPnO65Q8mh6F_LDSxjxa2Yzo_wdjhbPZLjfUJXgCzm54cClXzT5twzo7lzoAfaJlkTsoZc2HFWqmcri0BuzmTFLZx2Q7wYBm0pXHmQKF0V-C1O6NWfd4mfBhbM-I1tHYSpAMgarSm22WDMDx-WWI7TEzy2QhaBVaENW9BKaKkJklocAZCxk18WhR0fckIGiWiSM5FcU1PY2jfGsTmX505Ub7P5Dz75Ygqrutd5tFrcqyPAtPTFDk8X1InxkkUwpP3nFU5o50DGhwQolGYKPGtQ-ZtmbOfcWQ"
}
]
}
Walk through
- Run the mock oidc server using the start npm script
cd node-oidc-provider && npm start
- In Chrome make a request to auth http://localhost:9090/auth?client_id=foo&response_type=id_token&scope=openid&nonce=foobar
- The mock OIDC will return a 302 with a Location header of /interaction/8d33438f-a47d-4c7a-a3c1-f21415ed2330
- The browser will GET this location and render an authentication UI. Login with any user/pwd combo (it doesnt matter).
- Given this is a new authorisation, the mock OIDC will show an authorise UI.
- The (mock) OIDC provider will redirect to the specified redirection URI for the SPA app
https://bm4csarchbox.local:5001/#id_token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IkdpeUJFR0pkOEhlVVU1dVY4ZTZBWWpFeE1mVmhfUkFwS2dVZGJNNTJsSzQifQ.eyJzdWIiOiJhc2RmIiwibm9uY2UiOiJmb29iYXIiLCJhdWQiOiJmb28iLCJleHAiOjE1OTY5NzczMjQsImlhdCI6MTU5Njk3MzcyNCwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo5MDkwIn0.gUgULZHItWq1lBWmGv9xRsvQn_z7-I4by9FGI9OkpTgX1Gy61-gmKspNr8lbzkLV-O_8iuIgtVXukA70cjUwNDLPyelpoBrEMV_VM2BE-rHy3I9iFrpSaOk_wEEiofcBLswCjzKTt4Z9cSR8PxWTw7uIT6804yfHW8COCUo0Y7fJ5Q9SdNi3yvOvdLkBZoSRjKVlE1Y4ZcntGp6-GfK9WUqvTxrZjYlD3Cvo7r80hxVeUMVVD_ULPDCIdbz-YlK-JqKmB53Q5a_kCyF4Lp51Xsy1Fm5Mb3kxEBUw5edBiYMxR-b2ffY5Fry3w4LxoKeGGxjnU_Z8-QbRBHUtJRPvGg
Breaking the id_token
, which is separated into 3 parts (header, payload and signature), apart using jwt.io shows:
Header:
{
"alg": "RS256",
"typ": "JWT",
"kid": "GiyBEGJd8HeUU5uV8e6AYjExMfVh_RApKgUdbM52lK4"
}
Payload:
{
"sub": "bob",
"nonce": "foobar",
"aud": "foo",
"exp": 1596977324,
"iat": 1596973724,
"iss": "http://localhost:9090"
}
Signature:
RSASHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnZqp6eznx9bqbpVn85rr
O8LdGfxGqhhfL4ip8qOux1JIqse+zTrIYA5n//b867NI1h93eDvKOh8D1KYlCoHr
Y0GJM4RZiyh2ikyqIPrWQKoXYaVGl8YVgI3qJOQPkyMpq0ptt+rMluhLn9/gQK51
jamSb8gS+14FtG2If5k6zC7GtiFWeGvg0di8JvtgxKRFVlZRF2wWs5wB+kKuaQRR
kUJC37KNA4RKwe/9x1lkOGoImtIkRu403n+YIZ2ZW4EYwr0/WKwg6AjxWzMkSsub
gN8FXJkKx6ZNc243niO/anrlQu3lotlenQpEJrh+zEX6Hs67a9iDxJ6MaiOilAWq
7wIDAQAB
-----END PUBLIC KEY-----
)
Authentication and authorization for SPAs
The official .NET core docs provide a concrete implementation of using React, OIDC and JWT using IdentityServer4 and .NET core 3.1 web api.
The dotnet
CLI tooling will codegen a boilerplate solution including auth (with the au
flag):
dotnet new react -o <output_directory_name> -au Individual
The codegen spits out a bunch of React components and a .NET core backend wired up with nice IdentityServer extensions and defaults.
React components:
Login
login flowLogout
logout flowLoginMenu
authenticated aware navbar (e.g. login option if not logged in)AuthorizeRoute
used to protect client-side routes, like the vanilla react-routerRoute
, but requires authentication to render
export default class App extends Component {
static displayName = App.name;
render() {
return (
<Layout>
<Route exact path="/" component={Home} />
<Route path="/counter" component={Counter} />
<AuthorizeRoute path="/fetch-data" component={FetchData} />
<Route
path={ApplicationPaths.ApiAuthorizationPrefix}
component={ApiAuthorizationRoutes}
/>
</Layout>
);
}
}
To authenticate with a backend API the JWT token needs to be passed along. The FetchData
component shows this in action.
import authService from './api-authorization/AuthorizeService'
...
async populateWeatherData() {
const token = await authService.getAccessToken();
const response = await fetch('weatherforecast', {
headers: !token ? {} : { 'Authorization': `Bearer ${token}` }
});
const data = await response.json();
this.setState({ forecasts: data, loading: false });
}
Backend (.NET core 3.x) highlights:
- Middleware setup in
Startup.cs
sets up IdentityServer with some sane defaults and extensions usingservices.AddIdentityServer().AddApiAuthorization<ApplicationUser, ApplicationDbContext>();
. - With
services.AddAuthentication().AddIdentityServerJwt()
the default authentication scheme across the app is setup for IdentityServer to handle all requests under the/Identity
route, and theJwtBearerHandler
for everything else. - The
WeatherForecastController
is locked down with the[Authorize]
, which from above washes through theAddIdentityServerJwt
policy and in-turnJwtBearerHandler
. That is, a valid bearer (JWT) token must be supplied to hit/weatherforecast
. - A controller (
OidcConfigurationController
) is provided to serve up all the needed OIDC related paramters that a SPA client needs, by throwing a request at_configuration/{clientId}
appsettings.json
"IdentityServer": {
"Clients": {
"react_corewebapi_identityserver_oidc": {
"Profile": "IdentityServerSPA"
}
}
},
appsettings.Development.json
"IdentityServer": {
"Key": {
"Type": "Development"
}
}