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.

  1. React app sends an authentication request to the OpenID provider, passing client_id
  2. The user is authenticated.
  3. Details about user are encoded and signed into an id_token. This is passed back to the preconfigured redirect URI.
  4. 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.

  1. node.js webapp sends an authentication request to the OpenID provider, passing client_id
  2. The user is authenticated.
  3. A one-time code is passed back to the preconfigured redirect URI.
  4. 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.
  5. 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

  1. Run the mock oidc server using the start npm script cd node-oidc-provider && npm start
  2. In Chrome make a request to auth http://localhost:9090/auth?client_id=foo&response_type=id_token&scope=openid&nonce=foobar
  3. The mock OIDC will return a 302 with a Location header of /interaction/8d33438f-a47d-4c7a-a3c1-f21415ed2330
  4. The browser will GET this location and render an authentication UI. Login with any user/pwd combo (it doesnt matter).
  5. Given this is a new authorisation, the mock OIDC will show an authorise UI.
  6. 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 flow
  • Logout logout flow
  • LoginMenu authenticated aware navbar (e.g. login option if not logged in)
  • AuthorizeRoute used to protect client-side routes, like the vanilla react-router Route, 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 using services.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 the JwtBearerHandler for everything else.
  • The WeatherForecastController is locked down with the [Authorize], which from above washes through the AddIdentityServerJwt policy and in-turn JwtBearerHandler. 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"
    }
  }