OAuth 2.0 and OpenID Connect journey

Goal: step by step implement the spec

Addtions: for seamless integration we are going to add JWKS and OIDC discovery endpoints so clients will be able to verify tokens without knowing sign secret

At the very end integrating should be as easy as

using System.Security.Claims;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Identity.Web;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthorization().AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options => {
        options.Authority = "https://auth.localhost.direct";
        options.Audience = "my-awesome-api";
    });

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.MapGet("/", (ClaimsPrincipal user) => user.Claims.Select(c => new KeyValuePair<string, string>(c.Type, c.Value)).ToList());

app.Run();

so our service is discover JWKS from wellknown OIDC endpoint of our authority, retrieve public keys and validate incomming requests

Note: If that's not clear then everything below won't make any sense

Next we are going to have some oversimplified and short notes about the spec itself, but even so it will be veryyyyy long

Also before doing anything we will prepare some examples for providers like Google, Microsoft just to see how it supposed to work

And only after we are going to mimique something similar

In our first attempts we will start with something oversimplified - aka HSA256 and without validation at all, and only after will move to hardcoded RSA and then to JWKS

Whenever possible everything will be stored in memory, so storage is implementation detail, as well as basic CRUD for it

Please remember this is a note written by me for me, not an tutorial

1. OAuth 2.0 Spec - Introduction

1.1. Roles

  • resource owner - human, knows login and password, owns some data on resource server
  • resource server - service that holds user data
  • client - our frontend, mobile app, 3rd party integrations
  • authorization server - accepts credentials from user, generates access tokens, the thing we are going to implement

1.2. Flow

Taken from the spec as is

     +--------+                               +---------------+
     |        |--(A)- Authorization Request ->|   Resource    |
     |        |                               |     Owner     |
     |        |<-(B)-- Authorization Grant ---|               |
     |        |                               +---------------+
     |        |
     |        |                               +---------------+
     |        |--(C)-- Authorization Grant -->| Authorization |
     | Client |                               |     Server    |
     |        |<-(D)----- Access Token -------|               |
     |        |                               +---------------+
     |        |
     |        |                               +---------------+
     |        |--(E)----- Access Token ------>|    Resource   |
     |        |                               |     Server    |
     |        |<-(F)--- Protected Resource ---|               |
     +--------+                               +---------------+

                     Figure 1: Abstract Protocol Flow

1.3. Authorization Grant

1.3.1. Authorization Code

Most common approach, whenever you click on "Login with Whatever" button, popup shows with login form of authorization server

This approach is preferred because anyone except auth service does not see user credentials

If everything went fine auth service responds with code that later will be exchanged to access token

1.3.2. Implicit

The same as above, except instead of responding with code our auth service responds with access token

Used for SPA when there is no back channel

1.3.3. Resource Owner Password Credentials

Something similar to implicit but we own the login form, may be used in case of mobile app where we do not want to use webview

1.3.4. Client Credentials

Used for server to server communication, usually does not touch user data

TODO

There are many other flows, liky hybrid one, also there is a way to extend flows with something custom, need to figure out how to allow some services to act on behalf of user, technically it is done with authorization code with offline access

1.4. Access Token

Usually we are talking about JWT that is passed as authorization bearer request header

Described in details in RFC6750

1.5. Refresh Token

chart taken from spec as is


  +--------+                                           +---------------+
  |        |--(A)------- Authorization Grant --------->|               |
  |        |                                           |               |
  |        |<-(B)----------- Access Token -------------|               |
  |        |               & Refresh Token             |               |
  |        |                                           |               |
  |        |                            +----------+   |               |
  |        |--(C)---- Access Token ---->|          |   |               |
  |        |                            |          |   |               |
  |        |<-(D)- Protected Resource --| Resource |   | Authorization |
  | Client |                            |  Server  |   |     Server    |
  |        |--(E)---- Access Token ---->|          |   |               |
  |        |                            |          |   |               |
  |        |<-(F)- Invalid Token Error -|          |   |               |
  |        |                            +----------+   |               |
  |        |                                           |               |
  |        |--(G)----------- Refresh Token ----------->|               |
  |        |                                           |               |
  |        |<-(H)----------- Access Token -------------|               |
  +--------+           & Optional Refresh Token        +---------------+

               Figure 2: Refreshing an Expired Access Token

1.6. TLS Version

Whenever you playing with any OAuth or OIDC usually you are forced to use https

The exceptions may be for develpment when you set your callback urls to localhost

1.7. HTTP Redirections

Whenever redirection is used it is supposed to be 302

1.8. Interoperability

This spec does not touch following topics at all:

  • client registration
  • authorization server capabilities
  • endpoint discovery

2. Client Registration

Spec does not describe how to implement this

Think of following - whenever you intergrated "Login with something" button - as the very first steps all guides describe that you need go to you provider, create new "app" and define its allowed redirect urls - this is exactly the "client registration"

2.1. Client Types

  • confidential - clients having "backend"
  • public - clients without backends, aka SPA

Later in spec you may see following client profiles:

  • web application - confidential web site
  • user-agent-based application - public SPA
  • native application - public mobile apps

2.2. Client Identifier

When you play with all this, often you will deal with client_id and client_secret, the first one is our client identifier, usual some kind of GUID, unique to auth server

2.3. Client Authentication

And this one is about client_secret which is used by confidential clients, not shared, keeped in secrcet

2.3.1. Client Password

Sounds little bit missleading but all it is is a pair of two previous parts together, where client_id becomes username and client_secret - password

Spec describes that it can be used in one of two ways:

  • as basic authorization header, aka Authorization: Basic {base64(client_id + ":" + client_scert)}
  • as form url encoded client_id and client_password params

Notes:

  • only one approach may be used at same time
  • passing via query string is not allowed

3. Protocol Endpoints

The cool part - whole OAuth is just following endpoints:

  • authorization endpoint - will show you login form, perform redirects, etc
  • token endpoint - exchange code to access_token

3.1. Authorization Endpoint

Section describes that it shold be an endpoint to identify user (aka via his username and password), as result it is expected to have GET method handler to draw login form as well as POST handler

3.1.1. Response Type

There is one required parameter

response_type - the value MUST be one of code for requesting an authorization code, token for requesting an access token (implicit grant)

there can be other types, they are space delimited, order does not matter

3.1.2. Redirection Endpoint

The URL where we should redirect user after login

Must be absolute, may have query string params, must not include fragment

If we are adding our query strign params we should keep those were given to us

3.1.2.1. Endpoint Request Confidentiality

As usual - require https

3.1.2.2. Registration Requirements

Redirect URL are required for:

  • public clients
  • confidential clients utilizing the implicit grant type

Client is required to provide full redirect url with optional state query strign parameter

Client may have more than one redirect url registered

3.1.2.3. Dynamic Configuration

In case of multiple redirect urls being registered client must provide redirect url to authorization endpoint, otherwise how it should choose one

3.1.2.4. Invalid Endpoint

It is more like a note - if no redirect url provided or it is not found - auth service must not redirect user anywhere and should warn him about the error

3.1.2.5. Endpoint Content

Is is more like a note to not add any 3rd party scripts anywhere in the chain and urls

3.2. Token Endpoint

This endpoint is used to exchange code or refresh_token to access_token

This endpoint used with all flows except implicit (which receives access token directly)

It should accept only POST requests, parse only wellknown parameters and treat empty values as missing ones

3.2.1. Client Authentication

While talking to token endpoint clients should authenticate themselves with client_id and client_password especially for flows with refresh tokens

3.3. Access Token Scope

Space delimited case sensitive scopes, where order does not matter

If, for some reasone final access token will have less scopes the auth service should notify client about that by providing scope parameter

Scopes are optional and if client did not passed one, default shuld be used

4. Obtaining Authorization

Here the actual fun stuff begins with endpoints and examples of how it all works

4.1. Authorization Code Grant

chart is taken from spec as is

     +----------+
     | Resource |
     |   Owner  |
     |          |
     +----------+
          ^
          |
         (B)
     +----|-----+          Client Identifier      +---------------+
     |         -+----(A)-- & Redirection URI ---->|               |
     |  User-   |                                 | Authorization |
     |  Agent  -+----(B)-- User authenticates --->|     Server    |
     |          |                                 |               |
     |         -+----(C)-- Authorization Code ---<|               |
     +-|----|---+                                 +---------------+
       |    |                                         ^      v
      (A)  (C)                                        |      |
       |    |                                         |      |
       ^    v                                         |      |
     +---------+                                      |      |
     |         |>---(D)-- Authorization Code ---------'      |
     |  Client |          & Redirection URI                  |
     |         |                                             |
     |         |<---(E)----- Access Token -------------------'
     +---------+       (w/ Optional Refresh Token)

   Note: The lines illustrating steps (A), (B), and (C) are broken into
   two parts as they pass through the user-agent.

                     Figure 3: Authorization Code Flow

4.1.1. Authorization Request

  • response_type - REQUIRED. Value MUST be set to code
  • client_id - REQUIRED. The client identifier
  • redirect_uri - OPTIONAL. Required if client, for example have more than one redirect uri
  • scope - OPTIONAL. The scope of the access request
  • state - RECOMMENDED. Some value, used by client to track the user, auth service should include it in redirect response

Example:

GET /authorize?response_type=code&client_id=s6BhdRkqt3&state=xyz&redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb HTTP/1.1
Host: server.example.com

4.1.2. Authorization Response

  • code REQUIRED. Short lived (~10min) one time code that will be used by client to exchange it to access token. If possible and second attempt detected auth service should revoke all tokens previously issued for this code
  • state - REQUIRED if the "state" parameter was present in the client request.

Example:

HTTP/1.1 302 Found
Location: https://client.example.com/cb?code=SplxlOBeZQQYbYS6WxSbIA&state=xyz

4.1.2.1. Error Response

If client_id or redirect_uri are missing, invalid, mismatching - auth service must warn user and must not redirect him anywhere

If user did not allow access or some other error auth service redirects user to redirect uri with additional query string params

  • error - REQUIRED. enum:
    • invalid_request - validation error
    • unauthorized_client - client is not authorized for authorization code with given method
    • access_denied - either user or auth service denied request
    • unsupported_response_type - unsupported flow
    • invalid_scope - requested scope is invalid, unknown, or malformed.
    • server_error - unexpected error, aka 500
    • temporarily_unavailable - aka 503
  • error_description - OPTIONAL. Human-readable description.
  • error_uri - OPTIONAL. URL where user may read details about such error
  • state - REQUIRED if a "state" parameter was present in request

Example:

HTTP/1.1 302 Found
Location: https://client.example.com/cb?error=access_denied&state=xyz

4.1.3. Access Token Request

Exchange code or refresh token to access token

  • grant_type - REQUIRED. Value MUST be set to "authorization_code".
  • code - REQUIRED. The authorization code received from the authorization server.
  • redirect_uri - REQUIRED, if the "redirect_uri" parameter was included in the authorization request, and their values MUST be identical. (aka additional layer of security)
  • client_id - REQUIRED, if the client is not authenticating with the authorization server

Example:

POST /token HTTP/1.1
Host: server.example.com
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA&redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb

The authorization server MUST:

  • require client authentication for confidential clients or for any client that was issued client credentials
  • authenticate the client if client authentication is included
  • ensure that the authorization code was issued to the authenticated confidential client, or if the client is public, ensure that the code was issued to "client_id" in the request
  • verify that the authorization code is valid, and
  • ensure that the "redirect_uri" parameter is present if the "redirect_uri" parameter was included in the initial authorization request, and if included ensure that their values are identical.

4.1.4. Access Token Response

Example

HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache

{
  "access_token":"2YotnFZFEjr1zCsicMWpAA",
  "token_type":"example",
  "expires_in":3600,
  "refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA",
  "example_parameter":"example_value"
}

4.2. Implicit Grant

 +----------+
     | Resource |
     |  Owner   |
     |          |
     +----------+
          ^
          |
         (B)
     +----|-----+          Client Identifier     +---------------+
     |         -+----(A)-- & Redirection URI --->|               |
     |  User-   |                                | Authorization |
     |  Agent  -|----(B)-- User authenticates -->|     Server    |
     |          |                                |               |
     |          |<---(C)--- Redirection URI ----<|               |
     |          |          with Access Token     +---------------+
     |          |            in Fragment
     |          |                                +---------------+
     |          |----(D)--- Redirection URI ---->|   Web-Hosted  |
     |          |          without Fragment      |     Client    |
     |          |                                |    Resource   |
     |     (F)  |<---(E)------- Script ---------<|               |
     |          |                                +---------------+
     +-|--------+
       |    |
      (A)  (G) Access Token
       |    |
       ^    v
     +---------+
     |         |
     |  Client |
     |         |
     +---------+

   Note: The lines illustrating steps (A) and (B) are broken into two
   parts as they pass through the user-agent.

                       Figure 4: Implicit Grant Flow

4.2.1. Authorization Request

  • response_type - REQUIRED. Value MUST be set to token.
  • client_id - REQUIRED. The client identifier
  • redirect_uri - OPTIONAL.
  • scope - OPTIONAL. The scope of the access request
  • state - RECOMMENDED. state that will be passed to response

Example:

GET /authorize?response_type=token&client_id=s6BhdRkqt3&state=xyz&redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb HTTP/1.1
Host: server.example.com

4.2.2. Access Token Response

  • access_token - REQUIRED. actual access token
  • token_type - REQUIRED. The type of the token usually bearer
  • expires_in - RECOMMENDED. The lifetime in seconds of the access token. For example, the value "3600" denotes that the access token will expire in one hour from the time the response was generated.
  • scope - OPTIONAL, if identical to the scope requested by the client; otherwise, REQUIRED.
  • state - REQUIRED if the "state" parameter was present in request

The authorization server MUST NOT issue a refresh token.

HTTP/1.1 302 Found
Location: http://example.com/cb#access_token=2YotnFZFEjr1zCsicMWpAA&state=xyz&token_type=example&expires_in=3600

4.2.2.1. Error Response

  • error - REQUIRED. enum
    • invalid_request - validation
    • unauthorized_client - aka unknown client id
    • access_denied - access denied by user or auth
    • unsupported_response_type - not supported method
    • invalid_scope - scope invalid, unknown or malformed
    • server_error - aka 500
    • temporarily_unavailable - aka 503E.
  • error_description - OPTIONAL. Human-readable description
  • error_uri - OPTIONAL. url for details
  • state - REQUIRED if a "state" parameter was present in request

Example

HTTP/1.1 302 Found
Location: https://client.example.com/cb#error=access_denied&state=xyz

4.3. Resource Owner Password Credentials Grant

     +----------+
     | Resource |
     |  Owner   |
     |          |
     +----------+
          v
          |    Resource Owner
         (A) Password Credentials
          |
          v
     +---------+                                  +---------------+
     |         |>--(B)---- Resource Owner ------->|               |
     |         |         Password Credentials     | Authorization |
     | Client  |                                  |     Server    |
     |         |<--(C)---- Access Token ---------<|               |
     |         |    (w/ Optional Refresh Token)   |               |
     +---------+                                  +---------------+

            Figure 5: Resource Owner Password Credentials Flow

4.3.1. Authorization Request and Response

4.3.2. Access Token Request

  • grant_type - REQUIRED. Value MUST be set to password.
  • username - REQUIRED. The resource owner username.
  • password - REQUIRED. The resource owner password.
  • scope - OPTIONAL. The scope of the access request.
POST /token HTTP/1.1
Host: server.example.com
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
Content-Type: application/x-www-form-urlencoded

grant_type=password&username=johndoe&password=A3ddj3w

The authorization server MUST:

  • require client authentication for confidential clients or for any client that was issued client credentials
  • authenticate the client if client authentication is included, and
  • validate the resource owner password credentials using its existing password validation algorithm.

Protect this endpoint by rate limiter

4.3.3. Access Token Response

Example

HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache

{
  "access_token":"2YotnFZFEjr1zCsicMWpAA",
  "token_type":"example",
  "expires_in":3600,
  "refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA",
  "example_parameter":"example_value"
}

4.4. Client Credentials Grant

     +---------+                                  +---------------+
     |         |                                  |               |
     |         |>--(A)- Client Authentication --->| Authorization |
     | Client  |                                  |     Server    |
     |         |<--(B)---- Access Token ---------<|               |
     |         |                                  |               |
     +---------+                                  +---------------+

                     Figure 6: Client Credentials Flow

4.4.1. Authorization Request and Response

4.4.2. Access Token Request

  • grant_type - REQUIRED. Value MUST be set to client_credentials.
  • scope - OPTIONAL. The scope of the access request.

The client MUST authenticate with the authorization server.

Example:

POST /token HTTP/1.1
Host: server.example.com
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials

4.4.3. Access Token Response

Example:

HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache

{
  "access_token":"2YotnFZFEjr1zCsicMWpAA",
  "token_type":"example",
  "expires_in":3600,
  "example_parameter":"example_value"
}

4.5. Extension Grants

Custom grant types if supported may be passewd as uri

example

POST /token HTTP/1.1
Host: server.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Asaml2-
bearer&assertion=PEFzc2VydGlvbiBJc3N1ZUluc3RhbnQ9IjIwMTEtMDU
[...omitted for brevity...]aG5TdGF0ZW1lbnQ-PC9Bc3NlcnRpb24-

5. Issuing an Access Token

If everything fine - return access token and optionally refresh token, if something fails - return error response

5.1. Successful Response

  • access_token - REQUIRED. access token.
  • token_type - REQUIRED. usually bearer
  • expires_in - RECOMMENDED. The lifetime in seconds of the access token. For example, the value "3600" denotes that the access token will expire in one hour from the time the response was generated.
  • refresh_token - OPTIONAL. The refresh token, which can be used to obtain new access tokens using the same authorization grant.
  • scope - OPTIONAL, if identical to the scope requested by the client; otherwise, REQUIRED.

Example:

HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache

{
  "access_token":"2YotnFZFEjr1zCsicMWpAA",
  "token_type":"example",
  "expires_in":3600,
  "refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA",
  "example_parameter":"example_value"
}

5.2. Error Response

  • error - REQUIRED. enum:
    • invalid_request - validation
    • invalid_client - Client authentication failed (e.g., unknown client, no client authentication included, or unsupported authentication method). The authorization server MAY return an HTTP 401 (Unauthorized) status code to indicate which HTTP authentication schemes are supported. If the client attempted to authenticate via the "Authorization" request header field, the authorization server MUST respond with an HTTP 401 (Unauthorized) status code and include the "WWW-Authenticate" response header field matching the authentication scheme used by the client.
    • invalid_grant - The provided authorization grant (e.g., authorization code, resource owner credentials) or refresh token is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client.
    • unauthorized_client - The authenticated client is not authorized to use this authorization grant type.
    • unsupported_grant_type - The authorization grant type is not supported by the authorization server.
    • invalid_scope - The requested scope is invalid, unknown, malformed, or exceeds the scope granted by the resource owner.
  • error_description - OPTIONAL. Human-readable description
  • error_uri - OPTIONAL. URL with details

Example

HTTP/1.1 400 Bad Request
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache

{
  "error":"invalid_request"
}

6. Refreshing an Access Token

  • grant_type - REQUIRED. Value MUST be set to refresh_token.
  • refresh_token - REQUIRED. The refresh token issued to the client.
  • scope - OPTIONAL. If provided will be passed to response.
POST /token HTTP/1.1
Host: server.example.com
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token&refresh_token=tGzv3JOkF0XG5Qx2TlKWIA

The authorization server MUST:

  • require client authentication for confidential clients or for any client that was issued client credentials (or with other authentication requirements),
  • authenticate the client if client authentication is included and ensure that the refresh token was issued to the authenticated client, and
  • validate the refresh token.

The authorization server MAY issue a new refresh token, in which case the client MUST discard the old refresh token and replace it with the new refresh token. The authorization server MAY revoke the old refresh token after issuing a new refresh token to the client. If a new refresh token is issued, the refresh token scope MUST be identical to that of the refresh token included by the client in the request.

7. Accessing Protected Resources

7.1. Access Token Types

Example bearer

GET /resource/1 HTTP/1.1
Host: example.com
Authorization: Bearer mF_9.B5f-4.1JqM

Example mac

GET /resource/1 HTTP/1.1
Host: example.com
Authorization: MAC id="h480djs93hd8", nonce="274312:dj83hs9s", mac="kDZvddkndxvhGRXZhvuDjEWhGeE="

7.2. Error Response

Not much details here, except the recommendation to provide error, its description and optionally url

10. Security Considerations

10.1. Client Authentication

Client secret should be keeped secret, do not embed it to mobile apps or SPA

10.2. Client Impersonation

whenever possible we should authenticate client by his client id and secret

if that's not possible make sure to check client id

and in all cases check redirect urls

10.3. Access Tokens

must be transfered only via https

10.4. Refresh Tokens

We should keep track client id to whom refresh token was issued

Only via https

Whenever client authenticated we must check if refresh token was issued for this client or not

Example: For example, the authorization server could employ refresh token rotation in which a new refresh token is issued with every access token refresh response. The previous refresh token is invalidated but retained by the authorization server. If a refresh token is compromised and subsequently used by both the attacker and the legitimate client, one of them will present an invalidated refresh token, which will inform the authorization server of the breach.

10.5. Authorization Codes

Authorization codes MUST be short lived and single-use.

10.6. Authorization Code Redirection URI Manipulation

We must check if redirection uri is the same that was used to authenticate

10.7. Resource Owner Password Credentials

is is not recommended to use this flow it was added for legacy apps

10.8. Request Confidentiality

everything should be transmitted over https

state and scope parameters should not include confidential data

10.9. Ensuring Endpoint Authenticity

tls verification should be enabled

10.10. Credentials-Guessing Attacks

all secrets should be big so guessing won't be possible in reasonable time

10.11. Phishing Attacks

valid https

10.12. Cross-Site Request Forgery

client should implement CSRF and pass state

auth service should alos implement CSRF for its auth endpoint

10.13. Clickjacking

use x-frame-options to prevent embeding in iframes

10.14. Code Injection and Input Validation

The authorization server and client MUST sanitize (and validate when possible) any value received -- in particular, the value of the "state" and "redirect_uri" parameters.

10.15. Open Redirectors

Make suer that auth service can not be used as redirector without any validations

10.16. Misuse of Access Token to Impersonate Resource Owner in Implicit Flow

if possible - do not use implicit flow

Login with X

Now it is time to check how it looks like and works in providers

Each time we need to register an app, I will left here full examples but they won't be workfing because after checking things I will remove them

Steps:

  • register client and receive its credentials
  • retrieve access token from auth service
  • check scopes
  • send API call
  • refresh token

Scenarious:

Web Applications (WebForms, MVC)

web applications flow

Mobile apps (Android, iOS)

mobile apps

Client apps (SPA)

client apps

Devices (TV, XBOX)

devices

Service Accounts

sa

Google

Docs

So I have filled up the registration form with minimal required fields and received identity for my client

  • client_id: 98447827252-l09s8hofp0ek6tshhnc5l93e71li5mvv.apps.googleusercontent.com
  • client_secret: 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

Authorization Code Grant

From 4.1.1 we know that it is required to pass response_type=code and our client id, everything else is optional

From discovery document we know the authorization endpoint

So the end url we should open to user when he clicks login with button will be:

https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=98447827252-l09s8hofp0ek6tshhnc5l93e71li5mvv.apps.googleusercontent.com&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2Fcallback&scope=email%20profile

Notes: Google requires scope, so I have added email and profile

Opening this link should show google login page, and after user logs in you will be redirected back to:

http://localhost:5000/callback?code=4%2F0Adeu5BWehrhh2fFclFQn8_cwWuukfLYXMBVXAIsRNXgDbW66aAI39W-nDDL-EM7i-CoTqA&scope=email+profile+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile+openid&authuser=0&prompt=consent#

Now we can exchange received code to access_token by passing it to token endpoint we have found in discovery url

From 4.1.3 we know that it is required to authenticate client by providing client id and password, also we need to pass code we received and redirect_uri that was used, so the request will be:

curl -s -X POST -H "Content-Type: application/x-www-form-urlencoded" "https://oauth2.googleapis.com/token" -d "code=4%2F0Adeu5BWehrhh2fFclFQn8_cwWuukfLYXMBVXAIsRNXgDbW66aAI39W-nDDL-EM7i-CoTqA&client_id=98447827252-l09s8hofp0ek6tshhnc5l93e71li5mvv.apps.googleusercontent.com&client_secret=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&grant_type=authorization_code&redirect_uri=http://localhost:5000/callback"

And indeed we received our response with access token

{
  "access_token": "ya29.a0AfB_byD5HIEUFeALUFpqJ2AUM4tlbOioFuZeeftBvsh-8Y8Xl2dotb8xSwd10LN5iBOn2Lbbi2D0QftPKnJIqwaSJCBjJSBA8vtOMXDTanov19wvqKvHaOQHYgvcbp4_bSf2xpPOuaurqDzWaIuwObnGMgNXaCgYKAXISARASFQHsvYls1sBqtXDt-sJ6djCS_6ki6g0163",
  "expires_in": 3599,
  "scope": "https://www.googleapis.com/auth/userinfo.profile openid https://www.googleapis.com/auth/userinfo.email",
  "token_type": "Bearer",
  "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjkxMWUzOWUyNzkyOGFlOWYxZTlkMWUyMTY0NmRlOTJkMTkzNTFiNDQiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI5ODQ0NzgyNzI1Mi1sMDlzOGhvZnAwZWs2dHNoaG5jNWw5M2U3MWxpNW12di5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbSIsImF1ZCI6Ijk4NDQ3ODI3MjUyLWwwOXM4aG9mcDBlazZ0c2hobmM1bDkzZTcxbGk1bXZ2LmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29tIiwic3ViIjoiMTE0MjU5MDY0MTkxNzkwNjk2ODEzIiwiZW1haWwiOiJtYXJjaGVua28uYWxleGFuZHJAZ21haWwuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImF0X2hhc2giOiIzekFfNVZUVzY2dlNpeGhMLU9QR3h3IiwibmFtZSI6IkFsZXhhbmRyIE1hcmNoZW5rbyIsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS9BQWNIVHRkWTRXR29oek5TNXZJaG9neEF5aVRta0Rvc2FLYy00MFZoVGw1RUY4am5fanc9czk2LWMiLCJnaXZlbl9uYW1lIjoiQWxleGFuZHIiLCJmYW1pbHlfbmFtZSI6Ik1hcmNoZW5rbyIsImxvY2FsZSI6ImVuIiwiaWF0IjoxNjkxMjM3MjQ1LCJleHAiOjE2OTEyNDA4NDV9.4cqF51TwCPwuICYVADIGBqxYbyMesbo-Bhu8yZ9hSEoOwbi0C0GeNnyWJqV3lOIAkMaDRs2E51AnIkBGXRqD1eM75OoRkEnz0C_EdbuXA48QHmTyOWpF9Ia3u1LumTx5v6fo_NJAbbUdjgJaYgk80EhbtFuOh1kzy57YAOSMNHQWmtSgviDXZfHe4PaQueGsqv6hkk7s5Qoskgph5p_AeOWToSXEx-RaEACldKqqRDMok_lNc7pRZhtUxJrMaMs8e4VS1zPjtGLN1uT8-j80L-k5T09luTNufllmTM3o5X0R4dRVddREdnfJ46D67oJ2DP7aE2-EMBt91uRhqC2TMw"
}

OAuth 2.0 in nodejs

Before jumping into dotnet I wan't to build something really simple in nodejs

The reasons are:

  • no typing is good for prototyping
  • node has pretty good support for crypto stuff which will be helpful later
  • by intent all validations and etc are skipped to keep it small
  • each next eample is full version which should be runnable as is, it will make whole text bigger, but from my side is is just an embedding of yet another demo file that is runnable by itself, so just keep track of whats changed in comparison to previous step

So our starting point is kind of analog to dotnet minimal api:

HTTPS

For HTTP we are going to use get.localhost.direct its github can be found here, at moment of writing download link is aka.re/localhost and password is localhost

Inside archive there is localhost.direct.crt and localhost.direct.key files that may be used to serve our services under *.localhost.direct with valid certs, which will be helpfull to have fully working demo without the need to figure out how to disable HTTPS checks everywhere

const { readFileSync } = require('fs')
const http = require('http')
const https = require('https') // const http2 = require('http2')

http.createServer((req, res) => res.setHeader('Location', `https://${req.headers.host}${req.url}`).writeHead(302).end()).listen(80)

https.createServer({
  key: readFileSync('localhost.direct.key'),
  cert: readFileSync('localhost.direct.crt')
}, (req, res) => {
  console.log('HTTPS')
  res.writeHead(200)
  res.end('HTTPS\n')
}).listen(443)

Authorization Endpoint - Code

So lets try to implement both endpoints in minimal way

const crypto = require('crypto')
const { readFileSync } = require('fs')
const http = require('http')
const https = require('https') // const http2 = require('http2')

// As described in "2. Client Registration" and "2.3 Client Authentication" we should have at least `client_id` and `client_secret`, also `redirect_uris`, other possible settings will be `enabled`, `allowed_scopes`, etc
const clients = [{
  client_id: '1',
  client_secret: '2',
  redirect_uris: ['https://spa.localhost.direct/callback']
}]

const codes = []

http.createServer((req, res) => res.setHeader('Location', `https://${req.headers.host}${req.url}`).writeHead(302).end()).listen(80)

https.createServer({
  key: readFileSync('localhost.direct.key'),
  cert: readFileSync('localhost.direct.crt')
}, async (req, res) => {
  const url = new URL(req.url, `https://${req.headers.host}`)
  if (url.pathname === '/authorization') {
    return await authorization(req, res)
  }
  else if (url.pathname === '/token') {
    console.log('Token')
    res.writeHead(500)
    res.end('Not implemented\n')
  }
  // Handle not found
  else {
    console.log(`Not Found: ${req.url}`)
    return res.writeHead(404).end('Not Found\n')
  }
}).listen(443)

// Responsible for interaction with user, display authorization form, consent, etc
async function authorization(req, res) {
  if (req.method !== 'GET' && req.method !== 'POST') {
    console.log(`Method Not Allowed: ${req.method}`)
    return res.writeHead(405).end('Method Not Allowed\n')
  }

  const qs = new URL(req.url, `https://${req.headers.host}`).searchParams
  const response_type = qs.get('response_type')

  if (response_type === 'code') {
    return await authorization_code_grant(req, res)
  } else {
    console.log(`Unsupported response_type: ${response_type}`)
    return res.setHeader('content-type', 'text/html').writeHead(400).end('<h1>Invalid request</h1><p>Unsupported <code>response_type</code></p>\n')
  }
}

// 4.1. Authorization Code Grant
async function authorization_code_grant(req, res) {
  if (req.method === 'GET') {
    const qs = new URL(req.url, `https://${req.headers.host}`).searchParams
    // TODO: validate wellknown incomming parameters here, if something wrong - warn user and do not redirect him anywhere
    return res.setHeader('content-type', 'text/html').writeHead(200).end(`
      <fieldset>
        <legend>login</legend>
        <form method="POST">
          <table>
            <tr>
              <td>
                <label for="username">username</label>
              </td>
              <td>
                <input type="text" name="username" id="username" required />
              </td>
            </tr>
            <tr>
              <td>
                <label for="password">password</label>
              </td>
              <td>
                <input type="password" name="password" id="password" required />
              </td>
            </tr>
            <tr>
              <td>
              </td>
              <td>
                <input type="hidden" name="csrf" value="TODO: do not forget about CSRF" />
                <input type="submit" value="submit" />
              </td>
            </tr>
          </table>
        </form>
      </fieldset>
    `)
  } else {
    let body = ''
    req.on('data', chunk => body += chunk)
    req.on('end', () => {
      body = new URLSearchParams(body)
      const username = body.get('username')
      // const password = body.get('password')
      const qs = new URL(req.headers.referer).searchParams
      const response_type = qs.get('response_type')
      const client_id = qs.get('client_id')
      const redirect_uri = qs.get('redirect_uri')

      const code = crypto.randomUUID()
      // as described - code should have short lifetime (~10min) and if possible be one time use, also we are saving everything we can about request to validate it in future when exchanging
      codes.push({
        username,
        client_id,
        response_type,
        redirect_uri,
        code,
        at: Date.now()
      })

      const url = new URL(redirect_uri)
      if (qs.get('scope')) {
        url.searchParams.set('scope', qs.get('scope'))
      }
      url.searchParams.set('code', code)
      res.setHeader('Location', url).writeHead(302).end()
    })
  }
}

And if everything fine we should be able to access

https://auth.localhost.direct/authorization?response_type=code&client_id=client1&redirect_uri=https%3A%2F%2Fspa.localhost.direct%3A4200%2Fcallback

Just enter any username and password and submit the form, you should be redirected to

https://spa.localhost.direct:4200/callback?code=0f38ec84-39d0-4682-b3bf-6fcbecd174e0

Notes:

  • in this demo we did not validate anthything at all - it is really important to not forget about this
  • only code grant is implemented, as you can gues implementing implicit flow will be even simpler, but we postnote it by intent, so we can build our exchange endpoint first

After redirecting back, client uses received code in its backchannel to exchange it to access token

Here is another implementation where token endpoint added, once again as minimal as possible

Token Endpoint

const crypto = require('crypto')
const { readFileSync } = require('fs')
const http = require('http')
const https = require('https') // const http2 = require('http2')

// As described in "2. Client Registration" and "2.3 Client Authentication" we should have at least `client_id` and `client_secret`, also `redirect_uris`, other possible settings will be `enabled`, `allowed_scopes`, etc
const clients = [{
  client_id: '1',
  client_secret: '2',
  redirect_uris: ['https://spa.localhost.direct/callback']
}]

const codes = [{
  // for demo purposes, single code is hardcoded so we may skip authorization and call token directly
  username: 'mac',
  client_id: '1',
  response_type: 'code',
  redirect_uri: 'https://spa.localhost.direct/callback',
  code: '123',
  at: Date.now() - 60
}]

const tokens = []

http.createServer((req, res) => res.setHeader('Location', `https://${req.headers.host}${req.url}`).writeHead(302).end()).listen(80)

https.createServer({
  key: readFileSync('localhost.direct.key'),
  cert: readFileSync('localhost.direct.crt')
}, async (req, res) => {
  const url = new URL(req.url, `https://${req.headers.host}`)
  if (url.pathname === '/authorization') {
    return await authorization(req, res)
  }
  else if (url.pathname === '/token') {
    return await token(req, res)
  }
  else {
    console.log(`Not Found: ${req.url}`)
    return res.writeHead(404).end('Not Found\n')
  }
}).listen(443)

// Responsible for interaction with user, display authorization form, consent, etc
async function authorization(req, res) {
  if (req.method !== 'GET' && req.method !== 'POST') {
    console.log(`Method Not Allowed: ${req.method}`)
    return res.writeHead(405).end('Method Not Allowed\n')
  }

  const qs = new URL(req.url, `https://${req.headers.host}`).searchParams
  const response_type = qs.get('response_type')

  if (response_type === 'code') {
    return await authorization_code_grant(req, res)
  } else {
    console.log(`Unsupported response_type: ${response_type}`)
    return res.setHeader('content-type', 'text/html').writeHead(400).end('<h1>Invalid request</h1><p>Unsupported <code>response_type</code></p>\n')
  }
}

// 4.1. Authorization Code Grant
async function authorization_code_grant(req, res) {
  if (req.method === 'GET') {
    const qs = new URL(req.url, `https://${req.headers.host}`).searchParams
    // TODO: validate wellknown incomming parameters here, if something wrong - warn user and do not redirect him anywhere
    return res.setHeader('content-type', 'text/html').writeHead(200).end(`
      <fieldset>
        <legend>login</legend>
        <form method="POST">
          <table>
            <tr>
              <td>
                <label for="username">username</label>
              </td>
              <td>
                <input type="text" name="username" id="username" required />
              </td>
            </tr>
            <tr>
              <td>
                <label for="password">password</label>
              </td>
              <td>
                <input type="password" name="password" id="password" required />
              </td>
            </tr>
            <tr>
              <td>
              </td>
              <td>
                <input type="hidden" name="csrf" value="TODO: do not forget about CSRF" />
                <input type="submit" value="submit" />
              </td>
            </tr>
          </table>
        </form>
      </fieldset>
    `)
  } else {
    let body = ''
    req.on('data', chunk => body += chunk)
    req.on('end', () => {
      body = new URLSearchParams(body)
      const username = body.get('username')
      // const password = body.get('password')
      const qs = new URL(req.headers.referer).searchParams
      const response_type = qs.get('response_type')
      const client_id = qs.get('client_id')
      const redirect_uri = qs.get('redirect_uri')

      const code = crypto.randomUUID()
      // as described - code should have short lifetime (~10min) and if possible be one time use, also we are saving everything we can about request to validate it in future when exchanging
      codes.push({
        username,
        client_id,
        response_type,
        redirect_uri,
        code,
        at: Date.now()
      })

      const url = new URL(redirect_uri)
      if (qs.get('scope')) {
        url.searchParams.set('scope', qs.get('scope'))
      }
      url.searchParams.set('code', code)
      res.setHeader('Location', url).writeHead(302).end()
    })
  }
}


async function token(req, res) {
  let body = ''
    req.on('data', chunk => body += chunk)
    req.on('end', () => {
      body = new URLSearchParams(body)
      // const grant_type = body.get('grant_type')
      const redirect_uri = body.get('redirect_uri')
      const client_id = body.get('client_id')
      // const code = body.get('code')
      const code = codes.find(c => c.code === body.get('code'))
      // validate everything, do not forget that code is short lived and should be one time used

      // for simplicity we are using HS256, later it will be RSA and then JWKS, for not to keep things simple
      const expires = 3600
      const secret = 'HelloWorld_mac_was_here'
      const header = Buffer.from(JSON.stringify({
        typ: 'JWT',
        alg: 'HS256',
      })).toString('base64url')

      const payload = Buffer.from(JSON.stringify({
        sub: code.username,
        nbf: Date.now() - 30,
        iat: Date.now(),
        exp: Date.now() + expires,
        iss: 'https://auth.localhost.direct',
        aud: client_id,
      })).toString('base64url')

      const signature = crypto.createHmac('sha256', secret).update(`${header}.${payload}`).digest('base64url')

      return res.setHeader('content-type', 'application/json').writeHead(200).end(JSON.stringify({
        access_token: `${header}.${payload}.${signature}`,
        token_type: 'Bearer',
        expires_in: expires
      }))

    })
}

Once again, we do not have checks, but still need go via authorization endpoint and then use the code like so:

curl -s -X POST https://auth.localhost.direct/token \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "code=123" \
  --data-urlencode "redirect_uri=https://spa.localhost.direct:4200/callback" \
  --data-urlencode "client_id=1"

If everything fine, response will be something like:

{
  "access_token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJtYWMiLCJuYmYiOjE2OTEzMDczMzMwNTIsImlhdCI6MTY5MTMwNzMzMzA4MiwiZXhwIjoxNjkxMzA3MzM2NjgyLCJpc3MiOiJodHRwczovL2F1dGgubG9jYWxob3N0LmRpcmVjdCIsImF1ZCI6IjEifQ.DcRud510QNoZCYGPVnoaZQEyKeD6oZyUa2PJt2onWeI",
  "token_type":"Bearer",
  "expires_in":3600
}

and our token holds

{
  "sub": "mac",
  "nbf": 1691307224585,
  "iat": 1691307224615,
  "exp": 1691307228215,
  "iss": "https://auth.localhost.direct",
  "aud": "1"
}

So, technically that's it, literally that's is whole OAuth 2.0

Before moving further lets implmenet other flows

not sure if we will need all of them but from now it seems to be easy

Authorization Endpoint - Implicit

const crypto = require('crypto')
const { readFileSync } = require('fs')
const http = require('http')
const https = require('https') // const http2 = require('http2')

// As described in "2. Client Registration" and "2.3 Client Authentication" we should have at least `client_id` and `client_secret`, also `redirect_uris`, other possible settings will be `enabled`, `allowed_scopes`, etc
const clients = [{
  client_id: '1',
  client_secret: '2',
  redirect_uris: ['https://spa.localhost.direct/callback']
}]

const codes = [{
  // for demo purposes, single code is hardcoded so we may skip authorization and call token directly
  username: 'mac',
  client_id: '1',
  response_type: 'code',
  redirect_uri: 'https://spa.localhost.direct/callback',
  code: '123',
  at: Date.now() - 60
}]

const tokens = []

http.createServer((req, res) => res.setHeader('Location', `https://${req.headers.host}${req.url}`).writeHead(302).end()).listen(80)

https.createServer({
  key: readFileSync('localhost.direct.key'),
  cert: readFileSync('localhost.direct.crt')
}, async (req, res) => {
  const url = new URL(req.url, `https://${req.headers.host}`)
  if (url.pathname === '/authorization') {
    return await authorization(req, res)
  }
  else if (url.pathname === '/token') {
    return await token(req, res)
  }
  else {
    console.log(`Not Found: ${req.url}`)
    return res.writeHead(404).end('Not Found\n')
  }
}).listen(443)

// Responsible for interaction with user, display authorization form, consent, etc
async function authorization(req, res) {
  if (req.method !== 'GET' && req.method !== 'POST') {
    console.log(`Method Not Allowed: ${req.method}`)
    return res.writeHead(405).end('Method Not Allowed\n')
  }

  const qs = new URL(req.url, `https://${req.headers.host}`).searchParams
  const response_type = qs.get('response_type')

  if (response_type === 'code') {
    return await authorization_code_grant(req, res)
  }
  else if (response_type === 'token') {
    return await authorization_implicit_grant(req, res)
  }
  else {
    console.log(`Unsupported response_type: ${response_type}`)
    return res.setHeader('content-type', 'text/html').writeHead(400).end('<h1>Invalid request</h1><p>Unsupported <code>response_type</code></p>\n')
  }
}

// 4.1. Authorization Code Grant
async function authorization_code_grant(req, res) {
  if (req.method === 'GET') {
    const qs = new URL(req.url, `https://${req.headers.host}`).searchParams
    // TODO: validate wellknown incomming parameters here, if something wrong - warn user and do not redirect him anywhere
    return res.setHeader('content-type', 'text/html').writeHead(200).end(`
      <fieldset>
        <legend>login</legend>
        <form method="POST">
          <table>
            <tr>
              <td>
                <label for="username">username</label>
              </td>
              <td>
                <input type="text" name="username" id="username" required />
              </td>
            </tr>
            <tr>
              <td>
                <label for="password">password</label>
              </td>
              <td>
                <input type="password" name="password" id="password" required />
              </td>
            </tr>
            <tr>
              <td>
              </td>
              <td>
                <input type="hidden" name="csrf" value="TODO: do not forget about CSRF" />
                <input type="submit" value="submit" />
              </td>
            </tr>
          </table>
        </form>
      </fieldset>
    `)
  } else {
    let body = ''
    req.on('data', chunk => body += chunk)
    req.on('end', () => {
      body = new URLSearchParams(body)
      const username = body.get('username')
      // const password = body.get('password')
      const qs = new URL(req.headers.referer).searchParams
      const response_type = qs.get('response_type')
      const client_id = qs.get('client_id')
      const redirect_uri = qs.get('redirect_uri')

      const code = crypto.randomUUID()
      // as described - code should have short lifetime (~10min) and if possible be one time use, also we are saving everything we can about request to validate it in future when exchanging
      codes.push({
        username,
        client_id,
        response_type,
        redirect_uri,
        code,
        at: Date.now()
      })

      const url = new URL(redirect_uri)
      if (qs.get('scope')) {
        url.searchParams.set('scope', qs.get('scope'))
      }
      url.searchParams.set('code', code)
      res.setHeader('Location', url).writeHead(302).end()
    })
  }
}

// 4.2. Implicit Grant
async function authorization_implicit_grant(req, res) {
  if (req.method === 'GET') {
    const qs = new URL(req.url, `https://${req.headers.host}`).searchParams
    // TODO: validate wellknown incomming parameters here, if something wrong - warn user and do not redirect him anywhere

    // it is the same as in code flow
    return res.setHeader('content-type', 'text/html').writeHead(200).end(`
      <fieldset>
        <legend>login</legend>
        <form method="POST">
          <table>
            <tr>
              <td>
                <label for="username">username</label>
              </td>
              <td>
                <input type="text" name="username" id="username" required />
              </td>
            </tr>
            <tr>
              <td>
                <label for="password">password</label>
              </td>
              <td>
                <input type="password" name="password" id="password" required />
              </td>
            </tr>
            <tr>
              <td>
              </td>
              <td>
                <input type="hidden" name="csrf" value="TODO: do not forget about CSRF" />
                <input type="submit" value="submit" />
              </td>
            </tr>
          </table>
        </form>
      </fieldset>
    `)
  } else {
    let body = ''
    req.on('data', chunk => body += chunk)
    req.on('end', () => {
      body = new URLSearchParams(body)
      const username = body.get('username')
      // const password = body.get('password')
      const qs = new URL(req.headers.referer).searchParams
      const response_type = qs.get('response_type')
      const client_id = qs.get('client_id')
      const redirect_uri = qs.get('redirect_uri')
      const url = new URL(redirect_uri)

      // the difference here is that we are not storing/dealing with code anymore and goind to redirect to url passinng access token in url fragment
      // token generation copy pasted from token endpoint, later will be extracted, do not bother
      const expires = 3600
      const secret = 'HelloWorld_mac_was_here'
      const header = Buffer.from(JSON.stringify({
        typ: 'JWT',
        alg: 'HS256',
      })).toString('base64url')

      const payload = Buffer.from(JSON.stringify({
        sub: username,
        nbf: Date.now() - 30,
        iat: Date.now(),
        exp: Date.now() + expires,
        iss: 'https://auth.localhost.direct',
        aud: client_id,
      })).toString('base64url')

      const signature = crypto.createHmac('sha256', secret).update(`${header}.${payload}`).digest('base64url')

      const fragment = new URLSearchParams()
      fragment.set('access_token', `${header}.${payload}.${signature}`)
      fragment.set('token_type', 'Bearer')
      fragment.set('expires_in', expires)

      url.hash = fragment

      res.setHeader('Location', url).writeHead(302).end()
    })
  }
}

async function token(req, res) {
  let body = ''
    req.on('data', chunk => body += chunk)
    req.on('end', () => {
      body = new URLSearchParams(body)
      // const grant_type = body.get('grant_type')
      const redirect_uri = body.get('redirect_uri')
      const client_id = body.get('client_id')
      // const code = body.get('code')
      const code = codes.find(c => c.code === body.get('code'))
      // validate everything, do not forget that code is short lived and should be one time used

      // for simplicity we are using HS256, later it will be RSA and then JWKS, for not to keep things simple
      const expires = 3600
      const secret = 'HelloWorld_mac_was_here'
      const header = Buffer.from(JSON.stringify({
        typ: 'JWT',
        alg: 'HS256',
      })).toString('base64url')

      const payload = Buffer.from(JSON.stringify({
        sub: code.username,
        nbf: Date.now() - 30,
        iat: Date.now(),
        exp: Date.now() + expires,
        iss: 'https://auth.localhost.direct',
        aud: client_id,
      })).toString('base64url')

      const signature = crypto.createHmac('sha256', secret).update(`${header}.${payload}`).digest('base64url')

      return res.setHeader('content-type', 'application/json').writeHead(200).end(JSON.stringify({
        access_token: `${header}.${payload}.${signature}`,
        token_type: 'Bearer',
        expires_in: expires
      }))

    })
}

Notes:

  • as usual all validations/etc skipped
  • authorization endpoint login form is totally the same as in code grant
  • handling of post request is different, instead of dealing with code we create access token and add it to redirect url hash

If everything fine we should be able to open

https://auth.localhost.direct/authorization?response_type=token&client_id=1&redirect_uri=https%3A%2F%2Fspa.localhost.direct%3A4200%2Fcallback

and after login we will be redirected to

https://spa.localhost.direct:4200/callback#access_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhYWEiLCJuYmYiOjE2OTEzMDg2NTk2MjcsImlhdCI6MTY5MTMwODY1OTY1NywiZXhwIjoxNjkxMzA4NjYzMjU3LCJpc3MiOiJodHRwczovL2F1dGgubG9jYWxob3N0LmRpcmVjdCIsImF1ZCI6IjEifQ.9EWlJF8abRX8aTmNQ8JsPFQ7W6P6NUS2syNe9LM6Qi0&token_type=Bearer&expires_in=3600

So it is kind of the same as code grant except of skipping intermediate step with code exchange

Last two flows probably wont be used at all so I am skipping them for now, it should be really clear how to implement them following the spec

RSA asymmetric sign and verify

So far our token was signed by symmetric algo which requires us to share secret between parties to verify token which makes it useless if we want to give access to 3rd party clients

Instead we are going to use private public key pairs to sign the token

Public key will be shared to all 3rd party clients so they can verify the token

Here is an minimal outline of how it works

const crypto = require('crypto')

// just for example we are generating keys on the fly, there is quadrillion ways to create them, find more at https://github.com/mac2000/cryptography
const {privateKey, publicKey} = crypto.generateKeyPairSync('rsa', {
  modulusLength: 2048,
  publicKeyEncoding: {type: 'spki',format: 'pem'},
  privateKeyEncoding: {type: 'pkcs8', format: 'pem'}
})

const text = 'The string that we want to sign with our private key'
console.log('text', text)

const signature = crypto.sign('sha256', Buffer.from(text), privateKey).toString('base64url')
console.log('signature', signature)

const result = crypto.verify('sha256', Buffer.from(text), publicKey, Buffer.from(signature, 'base64url'))
console.log('result', result)

And here is something similar in plain bash

#!/usr/bin/env bash

# create keys
openssl genrsa -out private.pem 2048
openssl rsa -in private.pem -pubout -out public.pem

# the payload we are going to sign
echo "The string that we want to sign with our private key" > text.txt

# sign
openssl dgst -sha256 -sign private.pem -out text.sign text.txt

# verify
openssl dgst -sha256 -verify public.pem -signature text.sign text.txt

Pretty good description can be found here

Notes:

  • in bash you can try to play with base64 or hex to not store files but it is not purpose here and is left just for demo
  • in nodejs to read keys use something like crypto.createPublicKey(fs.readFileSync('public.pem', 'utf8'))
  • do not forget about paddings and other settings
  • do not forget to check your settings in other languages - aka how will you verify it in dotnet, golang, etc
  • in node to export keys use fs.writeFileSync('private.pem', privateKey, 'utf8') but do not forget to set key format to pem

With that in place, we can replace our token sign from HS256 to RSA256, sign tokens with private keys, and share public key to everyone, including 3rd party clients - it will allow them to use our services without the need to share secrets

JWKS

Here is the dilema - even so we have private public keys, how can we rotate them without disturbing 3rd party clients

The easier approach will be to have more than one pair of keys

So whenever we need rotate them - we will just add new pair to the list and little bit later remove old one from the list

Our clients expected to not hardcode public keys but instead receive them from well known endpoint

OpenId Connect Discovery Endpoint

Before proceeding to JWKS we need to have oidc discovery endpoint, so clients will be able

List of parameters can be found here:

https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata

Expected path is: /.well-known/openid-configuration

So lets create one with minial required params by looking at Goole endpoint as example

const crypto = require('crypto')
const { readFileSync } = require('fs')
const http = require('http')
const https = require('https') // const http2 = require('http2')

// As described in "2. Client Registration" and "2.3 Client Authentication" we should have at least `client_id` and `client_secret`, also `redirect_uris`, other possible settings will be `enabled`, `allowed_scopes`, etc
const clients = [{
  client_id: '1',
  client_secret: '2',
  redirect_uris: ['https://spa.localhost.direct/callback']
}]

const codes = [{
  // for demo purposes, single code is hardcoded so we may skip authorization and call token directly
  username: 'mac',
  client_id: '1',
  response_type: 'code',
  redirect_uri: 'https://spa.localhost.direct/callback',
  code: '123',
  at: Date.now() - 60
}]

const tokens = []

http.createServer((req, res) => res.setHeader('Location', `https://${req.headers.host}${req.url}`).writeHead(302).end()).listen(80)

https.createServer({
  key: readFileSync('localhost.direct.key'),
  cert: readFileSync('localhost.direct.crt')
}, async (req, res) => {
  const url = new URL(req.url, `https://${req.headers.host}`)
  if (url.pathname === '/.well-known/openid-configuration') {
    return await oidc(req, res)
  }
  else if (url.pathname === '/authorization') {
    return await authorization(req, res)
  }
  else if (url.pathname === '/token') {
    return await token(req, res)
  }
  else {
    console.log(`Not Found: ${req.url}`)
    return res.writeHead(404).end('Not Found\n')
  }
}).listen(443)

// Responsible for interaction with user, display authorization form, consent, etc
async function authorization(req, res) {
  if (req.method !== 'GET' && req.method !== 'POST') {
    console.log(`Method Not Allowed: ${req.method}`)
    return res.writeHead(405).end('Method Not Allowed\n')
  }

  const qs = new URL(req.url, `https://${req.headers.host}`).searchParams
  const response_type = qs.get('response_type')

  if (response_type === 'code') {
    return await authorization_code_grant(req, res)
  }
  else if (response_type === 'token') {
    return await authorization_implicit_grant(req, res)
  }
  else {
    console.log(`Unsupported response_type: ${response_type}`)
    return res.setHeader('content-type', 'text/html').writeHead(400).end('<h1>Invalid request</h1><p>Unsupported <code>response_type</code></p>\n')
  }
}

// 4.1. Authorization Code Grant
async function authorization_code_grant(req, res) {
  if (req.method === 'GET') {
    const qs = new URL(req.url, `https://${req.headers.host}`).searchParams
    // TODO: validate wellknown incomming parameters here, if something wrong - warn user and do not redirect him anywhere
    return res.setHeader('content-type', 'text/html').writeHead(200).end(`
      <fieldset>
        <legend>login</legend>
        <form method="POST">
          <table>
            <tr>
              <td>
                <label for="username">username</label>
              </td>
              <td>
                <input type="text" name="username" id="username" required />
              </td>
            </tr>
            <tr>
              <td>
                <label for="password">password</label>
              </td>
              <td>
                <input type="password" name="password" id="password" required />
              </td>
            </tr>
            <tr>
              <td>
              </td>
              <td>
                <input type="hidden" name="csrf" value="TODO: do not forget about CSRF" />
                <input type="submit" value="submit" />
              </td>
            </tr>
          </table>
        </form>
      </fieldset>
    `)
  } else {
    let body = ''
    req.on('data', chunk => body += chunk)
    req.on('end', () => {
      body = new URLSearchParams(body)
      const username = body.get('username')
      // const password = body.get('password')
      const qs = new URL(req.headers.referer).searchParams
      const response_type = qs.get('response_type')
      const client_id = qs.get('client_id')
      const redirect_uri = qs.get('redirect_uri')

      const code = crypto.randomUUID()
      // as described - code should have short lifetime (~10min) and if possible be one time use, also we are saving everything we can about request to validate it in future when exchanging
      codes.push({
        username,
        client_id,
        response_type,
        redirect_uri,
        code,
        at: Date.now()
      })

      const url = new URL(redirect_uri)
      if (qs.get('scope')) {
        url.searchParams.set('scope', qs.get('scope'))
      }
      url.searchParams.set('code', code)
      res.setHeader('Location', url).writeHead(302).end()
    })
  }
}

// 4.2. Implicit Grant
async function authorization_implicit_grant(req, res) {
  if (req.method === 'GET') {
    const qs = new URL(req.url, `https://${req.headers.host}`).searchParams
    // TODO: validate wellknown incomming parameters here, if something wrong - warn user and do not redirect him anywhere

    // it is the same as in code flow
    return res.setHeader('content-type', 'text/html').writeHead(200).end(`
      <fieldset>
        <legend>login</legend>
        <form method="POST">
          <table>
            <tr>
              <td>
                <label for="username">username</label>
              </td>
              <td>
                <input type="text" name="username" id="username" required />
              </td>
            </tr>
            <tr>
              <td>
                <label for="password">password</label>
              </td>
              <td>
                <input type="password" name="password" id="password" required />
              </td>
            </tr>
            <tr>
              <td>
              </td>
              <td>
                <input type="hidden" name="csrf" value="TODO: do not forget about CSRF" />
                <input type="submit" value="submit" />
              </td>
            </tr>
          </table>
        </form>
      </fieldset>
    `)
  } else {
    let body = ''
    req.on('data', chunk => body += chunk)
    req.on('end', () => {
      body = new URLSearchParams(body)
      const username = body.get('username')
      // const password = body.get('password')
      const qs = new URL(req.headers.referer).searchParams
      const response_type = qs.get('response_type')
      const client_id = qs.get('client_id')
      const redirect_uri = qs.get('redirect_uri')
      const url = new URL(redirect_uri)

      // the difference here is that we are not storing/dealing with code anymore and goind to redirect to url passinng access token in url fragment
      // token generation copy pasted from token endpoint, later will be extracted, do not bother
      const expires = 3600
      const secret = 'HelloWorld_mac_was_here'
      const header = Buffer.from(JSON.stringify({
        typ: 'JWT',
        alg: 'HS256',
      })).toString('base64url')

      const payload = Buffer.from(JSON.stringify({
        sub: username,
        nbf: Date.now() - 30,
        iat: Date.now(),
        exp: Date.now() + expires,
        iss: 'https://auth.localhost.direct',
        aud: client_id,
      })).toString('base64url')

      const signature = crypto.createHmac('sha256', secret).update(`${header}.${payload}`).digest('base64url')

      const fragment = new URLSearchParams()
      fragment.set('access_token', `${header}.${payload}.${signature}`)
      fragment.set('token_type', 'Bearer')
      fragment.set('expires_in', expires)

      url.hash = fragment

      res.setHeader('Location', url).writeHead(302).end()
    })
  }
}

async function token(req, res) {
  let body = ''
    req.on('data', chunk => body += chunk)
    req.on('end', () => {
      body = new URLSearchParams(body)
      // const grant_type = body.get('grant_type')
      const redirect_uri = body.get('redirect_uri')
      const client_id = body.get('client_id')
      // const code = body.get('code')
      const code = codes.find(c => c.code === body.get('code'))
      // validate everything, do not forget that code is short lived and should be one time used

      // for simplicity we are using HS256, later it will be RSA and then JWKS, for not to keep things simple
      const expires = 3600
      const secret = 'HelloWorld_mac_was_here'
      const header = Buffer.from(JSON.stringify({
        typ: 'JWT',
        alg: 'HS256',
      })).toString('base64url')

      const payload = Buffer.from(JSON.stringify({
        sub: code.username,
        nbf: Date.now() - 30,
        iat: Date.now(),
        exp: Date.now() + expires,
        iss: 'https://auth.localhost.direct',
        aud: client_id,
      })).toString('base64url')

      const signature = crypto.createHmac('sha256', secret).update(`${header}.${payload}`).digest('base64url')

      return res.setHeader('content-type', 'application/json').writeHead(200).end(JSON.stringify({
        access_token: `${header}.${payload}.${signature}`,
        token_type: 'Bearer',
        expires_in: expires
      }))

    })
}

// 3. OpenID Provider Metadata
// https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
async function oidc(req, res) {
  return res.setHeader('content-type', 'application/json').writeHead(200).end(JSON.stringify({
    issuer: 'https://auth.localhost.direct',
    authorization_endpoint: 'https://auth.localhost.direct/authorization',
    token_endpoint: 'https://auth.localhost.direct/token',
    jwks_uri: 'https://auth.localhost.direct/jwks', // TODO: we need to implement this in next step
    response_types_supported: ['code', 'token'], // [ "code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token", "none" ]
    subject_types_supported: ['public'], // [ "public", "pairwise" ]
    id_token_signing_alg_values_supported: ['RS256'], // [ "RS256", "ES256" ]
  }, null, 4))
}

Now we should be able to access:

https://auth.localhost.direct/.well-known/openid-configuration

and see

{
    "issuer": "https://auth.localhost.direct",
    "authorization_endpoint": "https://auth.localhost.direct/authorization",
    "token_endpoint": "https://auth.localhost.direct/token",
    "jwks_uri": "https://auth.localhost.direct/jwks",
    "response_types_supported": [
        "code",
        "token"
    ],
    "subject_types_supported": [
        "public"
    ],
    "id_token_signing_alg_values_supported": [
        "RS256"
    ]
}

The idea behind this - our services will discover this endpoint, find jwks endpoint from it, read public keys and use them to verify incomming requests

JWKS Endpoint

Now it is turn to implement JWKS endpoint

Before doing it, lets see what Google have, from it discovery endpoint we see that jwks endpoint is:

https://www.googleapis.com/oauth2/v3/certs

It responds with following json

{
  "keys": [
    {
      "n": "8KImylelEspnZ0X-ekZb9VPbUFhgB_yEPJuLKOhXOWJLVsU0hJP6B_mQOfVk0CHm66UsAhqV8qrINk-RXgwVaaFLMA827pbOOBhyvHsThcyo7AY5s6M7qbftFKKnkfVHO6c9TsQ9wpIfmhCVL3QgTlqlgFQWcNsY-qemSKpqvVi-We9I3kPvbTf0PKJ_rWA7GQQnU_GA5JRU46uvw4I1ODf0icNBHw7pWc7oTvmSl1G8OWABEyiFakcUG2Xd4qZnmWaKwLHBvifPuIyy2vK-yHH91mVZCuleVu53Vzj77RgUtF2EEuB-zizwC-fzaBmvnfx1kgQLsdK22J0Ivgu4Xw",
      "kty": "RSA",
      "alg": "RS256",
      "kid": "fd48a75138d9d48f0aa635ef569c4e196f7ae8d6",
      "use": "sig",
      "e": "AQAB"
    },
    {
      "n": "4kGxcWQdTW43aszLmftsGswmwDDKdfcse-lKeT_zjZTB2KGw9E6LVY6IThJVxzYF6mcyU-Z5_jDAW_yi7D_gXep2rxchZvoFayXynbhxyfjK6RtJ6_k30j-WpsXCSAiNAkupYHUyDIBNocvUcrDJsC3U65l8jl1I3nW98X6d-IlAfEb2In2f0fR6d-_lhIQZjXLupjymJduPjjA8oXCUZ9bfAYPhGYj3ZELUHkAyDpZNrnSi8hFVMSUSnorAt9F7cKMUJDM4-Uopzaqcl_f-HxeKvxN7NjiLSiIYaHdgtTpCEuNvsch6q6JTsllJNr3c__BxrG4UMlJ3_KsPxbcvXw",
      "kid": "911e39e27928ae9f1e9d1e21646de92d19351b44",
      "e": "AQAB",
      "alg": "RS256",
      "use": "sig",
      "kty": "RSA"
    }
  ]
}

And here is one for Microsoft

https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration

https://login.microsoftonline.com/common/discovery/v2.0/keys

In addition to properties from Google it has:

  • issuer - will be absolute url to our auth service
  • x5t - thumbprint of the x.509 cert (SHA-1 thumbprint)
  • x5c - x.509 certificate chain. The first entry in the array is the certificate to use for token verification; the other certificates can be used to verify this first certificate

But it seems that they are optional, otherwise how should everything work with Google for example

The spec for JWKS is here

https://datatracker.ietf.org/doc/html/rfc7517

Also there is pretty good description from OAuth0

https://auth0.com/docs/secure/tokens/json-web-tokens/json-web-key-set-properties

So we gonna need at least following properties:

  • kid - our key id, something unique per key, aka crypto.randomUUID() or in our case just hardcode something like key1
  • kty - RSA
  • alg - RSA256
  • use - sig
  • n - base64url modulus of public key
  • e - base64url exponent of public key

Note: according to spec it is required to have RSA256 that's why we do not bother and just implementing it

As about modulus and exponent here is how to retrieve them in nodejs

const crypto = require('crypto')

// just for example we are generating keys on the fly, there is quadrillion ways to create them, find more at https://github.com/mac2000/cryptography
const {privateKey, publicKey} = crypto.generateKeyPairSync('rsa', {
  modulusLength: 2048,
  publicKeyEncoding: {type: 'spki',format: 'pem'},
  privateKeyEncoding: {type: 'pkcs8', format: 'pem'}
})

// const publicKey = fs.readFileSync('public.pem');

const rsa = crypto.createPublicKey(publicKey);
const modulus = rsa.export({ type: 'pkcs1', format: 'der' }).subarray(28, 28 + 256).toString('base64url');
const exponent = rsa.export({ type: 'pkcs1', format: 'der' }).subarray(-3).toString('base64url');

console.log({n: modulus, e: exponent});

// -------------------------------------

console.log(crypto.createPublicKey(publicKey).export({ format: 'jwk' }))

Will definitely need a way to extract this info in other languages

Note: that values are expected to be base64url encoded

So having all that in place lets first implement jwks endpoint

const crypto = require('crypto')
const { readFileSync } = require('fs')
const http = require('http')
const https = require('https') // const http2 = require('http2')

// here are our keys that we will use to sign keys, also we will expose public key to the world
const {privateKey, publicKey} = crypto.generateKeyPairSync('rsa', {
  modulusLength: 2048,
  publicKeyEncoding: {type: 'spki',format: 'pem'},
  privateKeyEncoding: {type: 'pkcs8', format: 'pem'}
})

// As described in "2. Client Registration" and "2.3 Client Authentication" we should have at least `client_id` and `client_secret`, also `redirect_uris`, other possible settings will be `enabled`, `allowed_scopes`, etc
const clients = [{
  client_id: '1',
  client_secret: '2',
  redirect_uris: ['https://spa.localhost.direct/callback']
}]

const codes = [{
  // for demo purposes, single code is hardcoded so we may skip authorization and call token directly
  username: 'mac',
  client_id: '1',
  response_type: 'code',
  redirect_uri: 'https://spa.localhost.direct/callback',
  code: '123',
  at: Date.now() - 60
}]

const tokens = []

http.createServer((req, res) => res.setHeader('Location', `https://${req.headers.host}${req.url}`).writeHead(302).end()).listen(80)

https.createServer({
  key: readFileSync('localhost.direct.key'),
  cert: readFileSync('localhost.direct.crt')
}, async (req, res) => {
  const url = new URL(req.url, `https://${req.headers.host}`)
  if (url.pathname === '/.well-known/openid-configuration') {
    return await oidc(req, res)
  }
  else if (url.pathname === '/jwks') {
    return await jwks(req, res)
  }
  else if (url.pathname === '/authorization') {
    return await authorization(req, res)
  }
  else if (url.pathname === '/token') {
    return await token(req, res)
  }
  else {
    console.log(`Not Found: ${req.url}`)
    return res.writeHead(404).end('Not Found\n')
  }
}).listen(443)

// Responsible for interaction with user, display authorization form, consent, etc
async function authorization(req, res) {
  if (req.method !== 'GET' && req.method !== 'POST') {
    console.log(`Method Not Allowed: ${req.method}`)
    return res.writeHead(405).end('Method Not Allowed\n')
  }

  const qs = new URL(req.url, `https://${req.headers.host}`).searchParams
  const response_type = qs.get('response_type')

  if (response_type === 'code') {
    return await authorization_code_grant(req, res)
  }
  else if (response_type === 'token') {
    return await authorization_implicit_grant(req, res)
  }
  else {
    console.log(`Unsupported response_type: ${response_type}`)
    return res.setHeader('content-type', 'text/html').writeHead(400).end('<h1>Invalid request</h1><p>Unsupported <code>response_type</code></p>\n')
  }
}

// 4.1. Authorization Code Grant
async function authorization_code_grant(req, res) {
  if (req.method === 'GET') {
    const qs = new URL(req.url, `https://${req.headers.host}`).searchParams
    // TODO: validate wellknown incomming parameters here, if something wrong - warn user and do not redirect him anywhere
    return res.setHeader('content-type', 'text/html').writeHead(200).end(`
      <fieldset>
        <legend>login</legend>
        <form method="POST">
          <table>
            <tr>
              <td>
                <label for="username">username</label>
              </td>
              <td>
                <input type="text" name="username" id="username" required />
              </td>
            </tr>
            <tr>
              <td>
                <label for="password">password</label>
              </td>
              <td>
                <input type="password" name="password" id="password" required />
              </td>
            </tr>
            <tr>
              <td>
              </td>
              <td>
                <input type="hidden" name="csrf" value="TODO: do not forget about CSRF" />
                <input type="submit" value="submit" />
              </td>
            </tr>
          </table>
        </form>
      </fieldset>
    `)
  } else {
    let body = ''
    req.on('data', chunk => body += chunk)
    req.on('end', () => {
      body = new URLSearchParams(body)
      const username = body.get('username')
      // const password = body.get('password')
      const qs = new URL(req.headers.referer).searchParams
      const response_type = qs.get('response_type')
      const client_id = qs.get('client_id')
      const redirect_uri = qs.get('redirect_uri')

      const code = crypto.randomUUID()
      // as described - code should have short lifetime (~10min) and if possible be one time use, also we are saving everything we can about request to validate it in future when exchanging
      codes.push({
        username,
        client_id,
        response_type,
        redirect_uri,
        code,
        at: Date.now()
      })

      const url = new URL(redirect_uri)
      if (qs.get('scope')) {
        url.searchParams.set('scope', qs.get('scope'))
      }
      url.searchParams.set('code', code)
      res.setHeader('Location', url).writeHead(302).end()
    })
  }
}

// 4.2. Implicit Grant
async function authorization_implicit_grant(req, res) {
  if (req.method === 'GET') {
    const qs = new URL(req.url, `https://${req.headers.host}`).searchParams
    // TODO: validate wellknown incomming parameters here, if something wrong - warn user and do not redirect him anywhere

    // it is the same as in code flow
    return res.setHeader('content-type', 'text/html').writeHead(200).end(`
      <fieldset>
        <legend>login</legend>
        <form method="POST">
          <table>
            <tr>
              <td>
                <label for="username">username</label>
              </td>
              <td>
                <input type="text" name="username" id="username" required />
              </td>
            </tr>
            <tr>
              <td>
                <label for="password">password</label>
              </td>
              <td>
                <input type="password" name="password" id="password" required />
              </td>
            </tr>
            <tr>
              <td>
              </td>
              <td>
                <input type="hidden" name="csrf" value="TODO: do not forget about CSRF" />
                <input type="submit" value="submit" />
              </td>
            </tr>
          </table>
        </form>
      </fieldset>
    `)
  } else {
    let body = ''
    req.on('data', chunk => body += chunk)
    req.on('end', () => {
      body = new URLSearchParams(body)
      const username = body.get('username')
      // const password = body.get('password')
      const qs = new URL(req.headers.referer).searchParams
      const response_type = qs.get('response_type')
      const client_id = qs.get('client_id')
      const redirect_uri = qs.get('redirect_uri')
      const url = new URL(redirect_uri)

      // the difference here is that we are not storing/dealing with code anymore and goind to redirect to url passinng access token in url fragment
      // token generation copy pasted from token endpoint, later will be extracted, do not bother
      const expires = 3600
      const secret = 'HelloWorld_mac_was_here'
      const header = Buffer.from(JSON.stringify({
        typ: 'JWT',
        alg: 'HS256',
      })).toString('base64url')

      const payload = Buffer.from(JSON.stringify({
        sub: username,
        nbf: Date.now() - 30,
        iat: Date.now(),
        exp: Date.now() + expires,
        iss: 'https://auth.localhost.direct',
        aud: client_id,
      })).toString('base64url')

      const signature = crypto.createHmac('sha256', secret).update(`${header}.${payload}`).digest('base64url')

      const fragment = new URLSearchParams()
      fragment.set('access_token', `${header}.${payload}.${signature}`)
      fragment.set('token_type', 'Bearer')
      fragment.set('expires_in', expires)

      url.hash = fragment

      res.setHeader('Location', url).writeHead(302).end()
    })
  }
}

async function token(req, res) {
  let body = ''
    req.on('data', chunk => body += chunk)
    req.on('end', () => {
      body = new URLSearchParams(body)
      // const grant_type = body.get('grant_type')
      const redirect_uri = body.get('redirect_uri')
      const client_id = body.get('client_id')
      // const code = body.get('code')
      const code = codes.find(c => c.code === body.get('code'))
      // validate everything, do not forget that code is short lived and should be one time used

      // for simplicity we are using HS256, later it will be RSA and then JWKS, for not to keep things simple
      const expires = 3600
      const secret = 'HelloWorld_mac_was_here'
      const header = Buffer.from(JSON.stringify({
        typ: 'JWT',
        alg: 'HS256',
      })).toString('base64url')

      const payload = Buffer.from(JSON.stringify({
        sub: code.username,
        nbf: Date.now() - 30,
        iat: Date.now(),
        exp: Date.now() + expires,
        iss: 'https://auth.localhost.direct',
        aud: client_id,
      })).toString('base64url')

      const signature = crypto.createHmac('sha256', secret).update(`${header}.${payload}`).digest('base64url')

      return res.setHeader('content-type', 'application/json').writeHead(200).end(JSON.stringify({
        access_token: `${header}.${payload}.${signature}`,
        token_type: 'Bearer',
        expires_in: expires
      }))

    })
}

// 3. OpenID Provider Metadata
// https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
async function oidc(req, res) {
  return res.setHeader('content-type', 'application/json').writeHead(200).end(JSON.stringify({
    issuer: 'https://auth.localhost.direct',
    authorization_endpoint: 'https://auth.localhost.direct/authorization',
    token_endpoint: 'https://auth.localhost.direct/token',
    jwks_uri: 'https://auth.localhost.direct/jwks', // TODO: we need to implement this in next step
    response_types_supported: ['code', 'token'], // [ "code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token", "none" ]
    subject_types_supported: ['public'], // [ "public", "pairwise" ]
    id_token_signing_alg_values_supported: ['RS256'], // [ "RS256", "ES256" ]
  }, null, 4))
}

// https://datatracker.ietf.org/doc/html/rfc7517
async function jwks(req, res) {
  return res.setHeader('content-type', 'application/json').writeHead(200).end(JSON.stringify({
    keys: [{
      kid: '1',
      kty: 'RSA',
      alg: 'RS256',
      use: 'sig',
      n: crypto.createPublicKey(publicKey).export({ type: 'pkcs1', format: 'der' }).subarray(28, 28 + 256).toString('base64url'),
      e: crypto.createPublicKey(publicKey).export({ type: 'pkcs1', format: 'der' }).subarray(-3).toString('base64url')
    }]
  }, null, 4))
}

And we should be able to access

https://auth.localhost.direct/jwks

And see soemthing like

{
    "keys": [
        {
            "kid": "1",
            "kty": "RSA",
            "alg": "RS256",
            "use": "sig",
            "n": "t-WswdPi6x8mdO9kstaUB7SMRFRiIy2cRGrZt5soZJHPytfQtQ-uQksl5ksXj-Rqop2XdTpOkk0ktk2OpfHEbwOjAW-IRykC8wB9KGNaVNxB1Ox7IerBqi4S3AvdoYdDF2CanpTHlM_T3omBFccrWqonmZYpzEn2XlK6CoLICq9yyu2bLlgFGr863RXej6A4_MbT7MuJaFiyooEMfRaSgu0ajYMANl8Xb4zmJrgGGsF_sTf-GLKDH-CDWyO-yBn7vtwUzMEURl_NHpncHfzyIXjwxfCHmqileHfc_bBPa8bh2f0q7vtqJJi7_iFpAgMBAAE",
            "e": "AQAB"
        }
    ]
}

Auth Service

Now it is time to wireup everything into auth service

From last example the only thing is changed is that we are signing token with RSA

const crypto = require('crypto')
const { readFileSync } = require('fs')
const http = require('http')
const https = require('https') // const http2 = require('http2')

// here are our keys that we will use to sign keys, also we will expose public key to the world
const {privateKey, publicKey} = crypto.generateKeyPairSync('rsa', {
  modulusLength: 2048,
  publicKeyEncoding: {type: 'spki',format: 'pem'},
  privateKeyEncoding: {type: 'pkcs8', format: 'pem'}
})

// read private.pem into privateKey variable
// const privateKey = crypto.createPrivateKey(readFileSync('private.pem'))
// const publicKey = crypto.createPrivateKey(readFileSync('public.pem'))

// As described in "2. Client Registration" and "2.3 Client Authentication" we should have at least `client_id` and `client_secret`, also `redirect_uris`, other possible settings will be `enabled`, `allowed_scopes`, etc
const clients = [{
  client_id: '1',
  client_secret: '2',
  redirect_uris: ['https://spa.localhost.direct/callback']
}]

const codes = [{
  // for demo purposes, single code is hardcoded so we may skip authorization and call token directly
  username: 'mac',
  client_id: '1',
  response_type: 'code',
  redirect_uri: 'https://spa.localhost.direct/callback',
  code: '123',
  at: Date.now() - 60
}]

const tokens = []

http.createServer((req, res) => res.setHeader('Location', `https://${req.headers.host}${req.url}`).writeHead(302).end()).listen(80)

https.createServer({
  key: readFileSync('localhost.direct.key'),
  cert: readFileSync('localhost.direct.crt')
}, async (req, res) => {
  const url = new URL(req.url, `https://${req.headers.host}`)
  if (url.pathname === '/.well-known/openid-configuration') {
    return await oidc(req, res)
  }
  else if (url.pathname === '/jwks') {
    return await jwks(req, res)
  }
  else if (url.pathname === '/authorization') {
    return await authorization(req, res)
  }
  else if (url.pathname === '/token') {
    return await token(req, res)
  }
  else {
    console.log(`Not Found: ${req.url}`)
    return res.writeHead(404).end('Not Found\n')
  }
}).listen(443)

// Responsible for interaction with user, display authorization form, consent, etc
async function authorization(req, res) {
  if (req.method !== 'GET' && req.method !== 'POST') {
    console.log(`Method Not Allowed: ${req.method}`)
    return res.writeHead(405).end('Method Not Allowed\n')
  }

  const qs = new URL(req.url, `https://${req.headers.host}`).searchParams
  const response_type = qs.get('response_type')

  if (response_type === 'code') {
    return await authorization_code_grant(req, res)
  }
  else if (response_type === 'token') {
    return await authorization_implicit_grant(req, res)
  }
  else {
    console.log(`Unsupported response_type: ${response_type}`)
    return res.setHeader('content-type', 'text/html').writeHead(400).end('<h1>Invalid request</h1><p>Unsupported <code>response_type</code></p>\n')
  }
}

// 4.1. Authorization Code Grant
async function authorization_code_grant(req, res) {
  if (req.method === 'GET') {
    const qs = new URL(req.url, `https://${req.headers.host}`).searchParams
    // TODO: validate wellknown incomming parameters here, if something wrong - warn user and do not redirect him anywhere
    return res.setHeader('content-type', 'text/html').writeHead(200).end(`
      <fieldset>
        <legend>login</legend>
        <form method="POST">
          <table>
            <tr>
              <td>
                <label for="username">username</label>
              </td>
              <td>
                <input type="text" name="username" id="username" required />
              </td>
            </tr>
            <tr>
              <td>
                <label for="password">password</label>
              </td>
              <td>
                <input type="password" name="password" id="password" required />
              </td>
            </tr>
            <tr>
              <td>
              </td>
              <td>
                <input type="hidden" name="csrf" value="TODO: do not forget about CSRF" />
                <input type="submit" value="submit" />
              </td>
            </tr>
          </table>
        </form>
      </fieldset>
    `)
  } else {
    let body = ''
    req.on('data', chunk => body += chunk)
    req.on('end', () => {
      body = new URLSearchParams(body)
      const username = body.get('username')
      // const password = body.get('password')
      const qs = new URL(req.headers.referer).searchParams
      const response_type = qs.get('response_type')
      const client_id = qs.get('client_id')
      const redirect_uri = qs.get('redirect_uri')

      const code = crypto.randomUUID()
      // as described - code should have short lifetime (~10min) and if possible be one time use, also we are saving everything we can about request to validate it in future when exchanging
      codes.push({
        username,
        client_id,
        response_type,
        redirect_uri,
        code,
        at: Date.now()
      })

      const url = new URL(redirect_uri)
      if (qs.get('scope')) {
        url.searchParams.set('scope', qs.get('scope'))
      }
      url.searchParams.set('code', code)
      res.setHeader('Location', url).writeHead(302).end()
    })
  }
}

// 4.2. Implicit Grant
async function authorization_implicit_grant(req, res) {
  if (req.method === 'GET') {
    const qs = new URL(req.url, `https://${req.headers.host}`).searchParams
    // TODO: validate wellknown incomming parameters here, if something wrong - warn user and do not redirect him anywhere

    // it is the same as in code flow
    return res.setHeader('content-type', 'text/html').writeHead(200).end(`
      <fieldset>
        <legend>login</legend>
        <form method="POST">
          <table>
            <tr>
              <td>
                <label for="username">username</label>
              </td>
              <td>
                <input type="text" name="username" id="username" required />
              </td>
            </tr>
            <tr>
              <td>
                <label for="password">password</label>
              </td>
              <td>
                <input type="password" name="password" id="password" required />
              </td>
            </tr>
            <tr>
              <td>
              </td>
              <td>
                <input type="hidden" name="csrf" value="TODO: do not forget about CSRF" />
                <input type="submit" value="submit" />
              </td>
            </tr>
          </table>
        </form>
      </fieldset>
    `)
  } else {
    let body = ''
    req.on('data', chunk => body += chunk)
    req.on('end', () => {
      body = new URLSearchParams(body)
      const username = body.get('username')
      // const password = body.get('password')
      const qs = new URL(req.headers.referer).searchParams
      const response_type = qs.get('response_type')
      const client_id = qs.get('client_id')
      const redirect_uri = qs.get('redirect_uri')
      const url = new URL(redirect_uri)

      // the difference here is that we are not storing/dealing with code anymore and goind to redirect to url passinng access token in url fragment
      // token generation copy pasted from token endpoint, later will be extracted, do not bother
      const expires = 3600
      const secret = 'HelloWorld_mac_was_here'
      const header = Buffer.from(JSON.stringify({
        typ: 'JWT',
        alg: 'RS256',
        kid: '1'
      })).toString('base64url')

      const now = Math.floor(Date.now() / 1000)

      const payload = Buffer.from(JSON.stringify({
        sub: username,
        nbf: now - 30,
        iat: now,
        exp: now + expires,
        iss: 'https://auth.localhost.direct',
        aud: client_id
      })).toString('base64url')

      const signature = crypto.sign('sha256', Buffer.from(`${header}.${payload}`), privateKey).toString('base64url')

      const fragment = new URLSearchParams()
      fragment.set('access_token', `${header}.${payload}.${signature}`)
      fragment.set('token_type', 'Bearer')
      fragment.set('expires_in', expires)

      url.hash = fragment

      res.setHeader('Location', url).writeHead(302).end()
    })
  }
}

async function token(req, res) {
  let body = ''
    req.on('data', chunk => body += chunk)
    req.on('end', () => {
      body = new URLSearchParams(body)
      // const grant_type = body.get('grant_type')
      const redirect_uri = body.get('redirect_uri')
      const client_id = body.get('client_id')
      // const code = body.get('code')
      const code = codes.find(c => c.code === body.get('code'))
      // validate everything, do not forget that code is short lived and should be one time used

      // for simplicity we are using HS256, later it will be RSA and then JWKS, for not to keep things simple
      const expires = 3600
      const header = Buffer.from(JSON.stringify({
        typ: 'JWT',
        alg: 'RS256',
        kid: '1'
      })).toString('base64url')

      const now = Math.floor(Date.now() / 1000)

      const payload = Buffer.from(JSON.stringify({
        sub: code.username,
        nbf: now - 30,
        iat: now,
        exp: now + expires,
        iss: 'https://auth.localhost.direct',
        aud: client_id
      })).toString('base64url')

      const signature = crypto.sign('sha256', Buffer.from(`${header}.${payload}`), privateKey).toString('base64url')

      return res.setHeader('content-type', 'application/json').writeHead(200).end(JSON.stringify({
        access_token: `${header}.${payload}.${signature}`,
        token_type: 'Bearer',
        expires_in: expires
      }))

    })
}

// 3. OpenID Provider Metadata
// https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
async function oidc(req, res) {
  console.log('OIDC discovery')
  return res.setHeader('content-type', 'application/json').writeHead(200).end(JSON.stringify({
    issuer: 'https://auth.localhost.direct',
    authorization_endpoint: 'https://auth.localhost.direct/authorization',
    token_endpoint: 'https://auth.localhost.direct/token',
    jwks_uri: 'https://auth.localhost.direct/jwks', // TODO: we need to implement this in next step
    response_types_supported: ['code', 'token'], // [ "code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token", "none" ]
    subject_types_supported: ['public'], // [ "public", "pairwise" ]
    id_token_signing_alg_values_supported: ['RS256'], // [ "RS256", "ES256" ]
  }, null, 4))
}

// https://datatracker.ietf.org/doc/html/rfc7517
async function jwks(req, res) {
  console.log('JWKS')
  return res.setHeader('content-type', 'application/json').writeHead(200).end(JSON.stringify({
    keys: [
      // {
      //   kid: '1',
      //   kty: 'RSA',
      //   alg: 'RS256',
      //   use: 'sig',
      //   n: crypto.createPublicKey(publicKey).export({ type: 'pkcs1', format: 'der' }).subarray(28, 28 + 256).toString('base64url'),
      //   e: crypto.createPublicKey(publicKey).export({ type: 'pkcs1', format: 'der' }).subarray(-3).toString('base64url')
      // },
      {
        kid: '1',
        kty: 'RSA',
        alg: 'RS256',
        use: 'sig',
        ...crypto.createPublicKey(publicKey).export({ format: 'jwk' })
      }
  ]
  }, null, 4))
}

If everything fine we should be able to open:

https://auth.localhost.direct/authorization?response_type=code&client_id=client1&redirect_uri=https%3A%2F%2Fspa.localhost.direct%3A4200%2Fcallback

Enter any username and password and submit the form

We should be redirector to something like

https://spa.localhost.direct:4200/callback?code=eacf7f83-6bc9-47d1-9639-74d6f87f8a8c

The next step will be to exchange code to access_token like so:

curl -s -X POST https://auth.localhost.direct/token \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "code=eacf7f83-6bc9-47d1-9639-74d6f87f8a8c" \
  --data-urlencode "redirect_uri=https://spa.localhost.direct:4200/callback" \
  --data-urlencode "client_id=1"

And response should be

{"access_token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJSU0EyNTYifQ.eyJzdWIiOiJoZWxsbyIsIm5iZiI6MTY5MTMxNTIxODMwMSwiaWF0IjoxNjkxMzE1MjE4MzMxLCJleHAiOjE2OTEzMTUyMjE5MzEsImlzcyI6Imh0dHBzOi8vYXV0aC5sb2NhbGhvc3QuZGlyZWN0IiwiYXVkIjoiMSJ9.imetzopnd046Yx8nVKfpVTJxPEQbd7F85pZUC2ZgzlvohHrS1cR0utqrWL7LKqG7jLJhzO3yiIEwBHoQI9Z2FUWByXHTbmQHn7TA89LYEEcCLQa4bX1o50Vu8bM8mT4VMK9Gk_SDjv2fLqEmoiUs8SsTn24TN2K-Q1NtieuJUPhCS2gT3wgLkZjSBvgd3AHQ0GZkPnpKKOwn2NY7v1PJrp6oJlquYWXCbTms2CL0LaxbolSmxbDNgKcd6BekqpJHw8IiKAO_I5f3jz0lKJFn6Z5pPkAidqdmM8Q6ZocRDNajcnuAU3WQYUjnzkZviXxK-7uF3ChKx6Qp77Ceawc0Nw","token_type":"Bearer","expires_in":3600}

And our access token contains

{
  "sub": "hello",
  "nbf": 1691315218301,
  "iat": 1691315218331,
  "exp": 1691315221931,
  "iss": "https://auth.localhost.direct",
  "aud": "1"
}

For simplicity we may bypass authorization and just call:

curl -s -X POST https://auth.localhost.direct/token \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "code=123" \
  --data-urlencode "redirect_uri=https://spa.localhost.direct:4200/callback" \
  --data-urlencode "client_id=1"

And to check implicit flow we may open

https://auth.localhost.direct/authorization?response_type=token&client_id=client1&redirect_uri=https%3A%2F%2Fspa.localhost.direct%3A4200%2Fcallback

Which will redirect us to

https://spa.localhost.direct:4200/callback#access_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJSU0EyNTYifQ.eyJzdWIiOiJoZWxsbyIsIm5iZiI6MTY5MTMxNTM1MDA1MCwiaWF0IjoxNjkxMzE1MzUwMDgwLCJleHAiOjE2OTEzMTUzNTM2ODAsImlzcyI6Imh0dHBzOi8vYXV0aC5sb2NhbGhvc3QuZGlyZWN0IiwiYXVkIjoiY2xpZW50MSJ9.oLLmr4D2NfAeP0UDWNLMPTgPoCsSj7o7fPxlPBKPeuYEe6Tj2bWrvzYwHkNXceItXhHoCW3PLZ6e8Bz4FXmFLCr415in1Ze02hyexkR4Qvw5b9kxMfCkH-CVMGm4YTNskq8HBp3t0Y9SeHUTBlcH8FNVvNof6LxpLiO-9cqpfhiyUUx45PvxSUrnkH5Azz_8mh_MdRVGntKki88NKOE3nbdCJjNAsd_-TDqrtA-kP2p5rPCf0kIA4qqfi-vtzccxIPk-ZT_KAZH1z6eTljxDzY_3QF4zZCPAct5OiLBTqVI_BcRw5bTi7iZwwGoClEOkPdxC9tR2F8AZIYRrNxZL5g&token_type=Bearer&expires_in=3600

So our auth service is "implemented", let's see how it may be used

Comments Service

Let's pretend we have comments service, which is responsible for basic CRUD around comment entity, comments are belonging to users, so we need auth

using System.Security.Claims;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Identity.Web;

Microsoft.IdentityModel.Logging.IdentityModelEventSource.ShowPII = true; // for extended logging

/*
mkdir comments
cd comments
dotnet new web --no-https --exclude-launch-settings
dotnet add package Microsoft.Identity.Web
*/
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthorization().AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options => {
        options.Authority = "https://auth.localhost.direct";
        options.Audience = "1";

        // for extended logging
        options.Events = new JwtBearerEvents
        {
            OnMessageReceived = context =>
            {
                ILogger logger = context.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("JwtBearer");
                logger.LogDebug("Message received: {0}", context.Token);
                return Task.CompletedTask;
            },
            OnAuthenticationFailed = context =>
            {
                ILogger logger = context.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("JwtBearer");
                logger.LogError("Authentication failed: {0}", context.Exception);
                return Task.CompletedTask;
            },
            OnTokenValidated = context =>
            {
                ILogger logger = context.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("JwtBearer");
                logger.LogDebug("Token validated: {0}", context.SecurityToken);
                return Task.CompletedTask;
            }
        };
    });

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.MapGet("/", (ClaimsPrincipal user) => user.Claims.Select(c => new KeyValuePair<string, string>(c.Type, c.Value)).ToList());

app.Run();

Now we can call it with our access token and magic should happen

token=$(curl -s -X POST https://auth.localhost.direct/token \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "code=123" \
  --data-urlencode "redirect_uri=https://spa.localhost.direct:4200/callback" \
  --data-urlencode "client_id=1" | jq -r ".access_token")
curl http://localhost:5000/ -H "Authorization: Bearer $token"

It did not worked out, after adding some logging/debuging it become obvious that I forget to add kid to access_token, indeed, how should our service verify it if it does not know which key was used to sign it

Also note that kid should be added to header, not payload

It was almost working but I receive an verification error suggesting to read

https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet/wiki/SecurityTokenInvalidSignatureException:-IDX10511

After a while it seems that my attempts of extracting module and exponent were incorrect but thankfully node has built in functionality for this

crypto.createPublicKey(publicKey).export({ format: 'jwk' })

will give us

{
  "kty": "RSA",
  "n": "4XlwmS06sKznL7jQ3-Q70Q_zNRc_gcWC27hCe741vqEd9q3u7yXXyV_UJS7Jl_zC-MlYgE9kelRaaEMCAXpF47TZxG4-kDORGBG-sBOeqUZ5Aqa-BG7B4XSEoQEaadE9e1Sh7zvI2rHrp8g3RsqIeOpVyMEoi7M3YAS_1BQTOy42RKDmYt_oSZSJxFsk7-eKTj1d4Hno6Ot7ddbJ6AtD0ySrYvC-pNh-hRogZy9TKBtjCCY87MWtWYXASkf2u2aUKGWDul9PTmYf93qCg7jKZRDwztU3YwVImr-2kAV2KWuBH9hs9-_drBFaXgA5j2jDlYfSSzAQgMqoR3nS5oE6cw",
  "e": "AQAB"
}

which seems to be correct because dotnet now complained about expiration time

and this one was easy because in node we have milliseconds for Date.now() need to convert it to seconds

and finally it did worked out

from logs of auth service I see that first oidc discovery wasa called, then jwks

from dotnet side in response I see

[
  {
    "key": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier",
    "value": "mac"
  },
  { "key": "nbf", "value": "1691318184" },
  { "key": "iat", "value": "1691318214" },
  { "key": "exp", "value": "1691321814" },
  { "key": "iss", "value": "https://auth.localhost.direct" },
  { "key": "aud", "value": "1" }
]

and as you can guess subsecond calls dotnet does not call jwks anymore and uses cached keys

With that in place we have minimal OIDC/OAuth like auth service that works the same ways as Google/Microsoft

The next big topis are:

  • refresh tokens in general - especialy for 3rd party, so they won't know user credentials but can talk to our services on behalf user
  • refresh tokens for SPA - implicit flow does not allow refresh token to be passed, in oidc there is dedicated hybrid flow
  • service to service api key replacement - instead of sharing api keys between services they should talk to each other with access tokens in passwordless manner
  • ability for our services to do something on behalf of user - only for our services, when we need to change something on behalf user in another service
  • scopes - aka roles/privileges
  • platforms - something similar in other platforms, for not dotnet

Scopes

OAuth 2.0 spec does not specify much details about refresh tokens except the note that they are not supposed to be used in implicit flow

OIDC on the other hand has dedicated scope offline_access which asks auth service to return refresh token as well

That's why we need to figure out how to work with scopes before everything else

Scopes are just space separated case sensitive list of ascii strings where order does not matter

OAuth itself does not define the scopes, but OIDC does

Here are some links:

Here are some of them:

  • openid - required, client notifies auth service that it wants oidc, so id token should have sub, iss, aud, exp, iat, and at_hash. The last one used to verify id token
  • profile - optional, name, family_name, given_name, middle_name, nickname, preferred_username, profile, picture, website, gender, birthdate, zoneinfo, locale, and updated_at.
  • email - optional, email and email_verified
  • address - optional, address
  • phone - optional, phone_number and phone_number_verified

Note: all this is passed with id_token, not access_token here is our Google example revisited

https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=98447827252-l09s8hofp0ek6tshhnc5l93e71li5mvv.apps.googleusercontent.com&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2Fcallback&scope=openid%20%20profile%20email%20address%20phone

Note: we asced for openid, profile, email, address and phone scopes

Responds with:

http://localhost:5000/callback?code=4%2F0Adeu5BW_sYQH2snUfhgc9r-xVbEkYyp0Br0_dffXV2io87Z61sNnOrCAsrd4f48qDQpaeQ&scope=email+profile+openid+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email&authuser=0&prompt=consent#

As described in spec, because scopes are little bit different - authorization endpoint returns us new list of scopes

Now let's exchange it

curl -s -X POST "https://oauth2.googleapis.com/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "code=4%2F0Adeu5BW_sYQH2snUfhgc9r-xVbEkYyp0Br0_dffXV2io87Z61sNnOrCAsrd4f48qDQpaeQ" \
  --data-urlencode "client_id=98447827252-l09s8hofp0ek6tshhnc5l93e71li5mvv.apps.googleusercontent.com" \
  --data-urlencode "client_secret=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" \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "redirect_uri=http%3A%2F%2Flocalhost%3A5000%2Fcallback"

But Google will complain that: You can't sign in to this app because it doesn't comply with Google's OAuth 2.0 policy for keeping apps secure.

So I have reduced number of scopes, added them to app, published app and finally it worked out:

https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=98447827252-l09s8hofp0ek6tshhnc5l93e71li5mvv.apps.googleusercontent.com&redirect_uri=https%3A%2F%2Fmac-blog.org.ua%2Fcallback&scope=openid%20profile%20email&state=state&nonce=nonce
https://mac-blog.org.ua/callback?state=state&code=4%2F0Adeu5BWxpzHn1HEMpqw7nlR7CZxL0PcCEcKvaOU-gz6EzjMtjAqwEgQlWFY6XQSv4RjsEg&scope=email+profile+openid+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile&authuser=0&prompt=none#
curl -s -X POST "https://oauth2.googleapis.com/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "code=4%2F0Adeu5BWxpzHn1HEMpqw7nlR7CZxL0PcCEcKvaOU-gz6EzjMtjAqwEgQlWFY6XQSv4RjsEg" \
  -d "client_id=98447827252-l09s8hofp0ek6tshhnc5l93e71li5mvv.apps.googleusercontent.com" \
  -d "client_secret=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" \
  -d "grant_type=authorization_code" \
  -d "redirect_uri=https%3A%2F%2Fmac-blog.org.ua%2Fcallback"

response is:

{
  "access_token": "ya29.a0AfB_byBEO4zuBfFs1iaqYCAz_re82KlNDo_l3__v5IrpfKFmgcacEQ6kjlxcf1GXSZOJOwLJ2hMPTNuLYQawWqBDgBL4TichdrWsScwNLsQW3xtl_gwjnzVKPzkiIUnjTCvBoOXkv6ON75fFwM55p2aNKHsUJQaCgYKAUwSARASFQHsvYlsnFZWKb58_Cndxrk5LC0kdw0165",
  "expires_in": 3580,
  "scope": "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email openid",
  "token_type": "Bearer",
  "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjkxMWUzOWUyNzkyOGFlOWYxZTlkMWUyMTY0NmRlOTJkMTkzNTFiNDQiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI5ODQ0NzgyNzI1Mi1sMDlzOGhvZnAwZWs2dHNoaG5jNWw5M2U3MWxpNW12di5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbSIsImF1ZCI6Ijk4NDQ3ODI3MjUyLWwwOXM4aG9mcDBlazZ0c2hobmM1bDkzZTcxbGk1bXZ2LmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29tIiwic3ViIjoiMTE0MjU5MDY0MTkxNzkwNjk2ODEzIiwiZW1haWwiOiJtYXJjaGVua28uYWxleGFuZHJAZ21haWwuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImF0X2hhc2giOiJWaHVJcVFzb2xxNnJVb2FJT0l1ZG5RIiwibm9uY2UiOiJub25jZSIsIm5hbWUiOiJBbGV4YW5kciBNYXJjaGVua28iLCJwaWN0dXJlIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUFjSFR0ZFk0V0dvaHpOUzV2SWhvZ3hBeWlUbWtEb3NhS2MtNDBWaFRsNUVGOGpuX2p3PXM5Ni1jIiwiZ2l2ZW5fbmFtZSI6IkFsZXhhbmRyIiwiZmFtaWx5X25hbWUiOiJNYXJjaGVua28iLCJsb2NhbGUiOiJlbiIsImlhdCI6MTY5MTMyOTY5NywiZXhwIjoxNjkxMzMzMjk3fQ.CFpHDFzO5fmslVkniLAnZfKR17hFXrRQUEjmVz7H8TmT9hWI0nVZZtIzhwmZhdX86L3ROOvJmR5TY2ocvHjbOOTVHShp9okaXbv-n9blusiY1X6LuNifUezck-uAvu3tWITokaYuqSVdsmKEyijhzozth9ZuBQtR9jhyg2MeL_byqt9_mFb1n6Jjx_H5nbqCK5e_fSVb2WDEESqeBdyEu5-KJ-a57DaDjeloaegGWKF0X1sdkea_-Yy9IaAbCYqB4pQLMANgvy6DftdpDAfMvVrI5rf30F0DChhxR8s7z2Vzh68yaWfICgzg-c2C-WqCd7-mQhk5OVxMKx_qeNXmoQ"
}

access token is something special and is not JWT, which is fine and prevents missconfsution

id token

{
  "iss": "https://accounts.google.com",
  "azp": "98447827252-l09s8hofp0ek6tshhnc5l93e71li5mvv.apps.googleusercontent.com",
  "aud": "98447827252-l09s8hofp0ek6tshhnc5l93e71li5mvv.apps.googleusercontent.com",
  "sub": "114259064191790696813",
  "email": "[email protected]",
  "email_verified": true,
  "at_hash": "VhuIqQsolq6rUoaIOIudnQ",
  "nonce": "nonce",
  "name": "Alexandr Marchenko",
  "picture": "https://lh3.googleusercontent.com/a/AAcHTtdY4WGohzNS5vIhogxAyiTmkDosaKc-40VhTl5EF8jn_jw=s96-c",
  "given_name": "Alexandr",
  "family_name": "Marchenko",
  "locale": "en",
  "iat": 1691329697,
  "exp": 1691333297
}

Pretty good explanation is here: ID Tokens VS Access Tokens: What's the Difference?

ID Token and Access Token: What's the Difference?

Image taken from here

Also note that even so we did not asked for it in our initial request we had response_type=code not response_type=code%20id_token Google still returns id token to us

id token from Google has life time for 1hr

In header it has kid so we can verify it the same way as we did for access tokens

It has at_hash that can be used to verify received access_token something like this

Here is an bash example

const crypto = require('crypto')

const {access_token, id_token} = {
  "access_token": "ya29.a0AfB_byBEO4zuBfFs1iaqYCAz_re82KlNDo_l3__v5IrpfKFmgcacEQ6kjlxcf1GXSZOJOwLJ2hMPTNuLYQawWqBDgBL4TichdrWsScwNLsQW3xtl_gwjnzVKPzkiIUnjTCvBoOXkv6ON75fFwM55p2aNKHsUJQaCgYKAUwSARASFQHsvYlsnFZWKb58_Cndxrk5LC0kdw0165",
  "expires_in": 3580,
  "scope": "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email openid",
  "token_type": "Bearer",
  "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjkxMWUzOWUyNzkyOGFlOWYxZTlkMWUyMTY0NmRlOTJkMTkzNTFiNDQiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI5ODQ0NzgyNzI1Mi1sMDlzOGhvZnAwZWs2dHNoaG5jNWw5M2U3MWxpNW12di5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbSIsImF1ZCI6Ijk4NDQ3ODI3MjUyLWwwOXM4aG9mcDBlazZ0c2hobmM1bDkzZTcxbGk1bXZ2LmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29tIiwic3ViIjoiMTE0MjU5MDY0MTkxNzkwNjk2ODEzIiwiZW1haWwiOiJtYXJjaGVua28uYWxleGFuZHJAZ21haWwuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImF0X2hhc2giOiJWaHVJcVFzb2xxNnJVb2FJT0l1ZG5RIiwibm9uY2UiOiJub25jZSIsIm5hbWUiOiJBbGV4YW5kciBNYXJjaGVua28iLCJwaWN0dXJlIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUFjSFR0ZFk0V0dvaHpOUzV2SWhvZ3hBeWlUbWtEb3NhS2MtNDBWaFRsNUVGOGpuX2p3PXM5Ni1jIiwiZ2l2ZW5fbmFtZSI6IkFsZXhhbmRyIiwiZmFtaWx5X25hbWUiOiJNYXJjaGVua28iLCJsb2NhbGUiOiJlbiIsImlhdCI6MTY5MTMyOTY5NywiZXhwIjoxNjkxMzMzMjk3fQ.CFpHDFzO5fmslVkniLAnZfKR17hFXrRQUEjmVz7H8TmT9hWI0nVZZtIzhwmZhdX86L3ROOvJmR5TY2ocvHjbOOTVHShp9okaXbv-n9blusiY1X6LuNifUezck-uAvu3tWITokaYuqSVdsmKEyijhzozth9ZuBQtR9jhyg2MeL_byqt9_mFb1n6Jjx_H5nbqCK5e_fSVb2WDEESqeBdyEu5-KJ-a57DaDjeloaegGWKF0X1sdkea_-Yy9IaAbCYqB4pQLMANgvy6DftdpDAfMvVrI5rf30F0DChhxR8s7z2Vzh68yaWfICgzg-c2C-WqCd7-mQhk5OVxMKx_qeNXmoQ"
}

const {at_hash} = JSON.parse(Buffer.from(id_token.split('.')[1], 'base64url').toString())
const hash = crypto.createHash('sha256').update(access_token).digest().subarray(0, 16).toString('base64url')

console.log({at_hash, hash, equal: at_hash === hash})
/*
{
  at_hash: 'VhuIqQsolq6rUoaIOIudnQ',
  hash: 'VhuIqQsolq6rUoaIOIudnQ',
  equal: true
}
*/

Just from curiosity, if we will try response_type=code id_token

https://accounts.google.com/o/oauth2/v2/auth?response_type=code%20id_token&client_id=98447827252-l09s8hofp0ek6tshhnc5l93e71li5mvv.apps.googleusercontent.com&redirect_uri=https%3A%2F%2Fmac-blog.org.ua%2Fcallback&scope=openid%20profile%20email&state=state&nonce=nonce

Response will be

https://mac-blog.org.ua/callback#state=state&code=4/0Adeu5BX4aWtmO8bYiaMIUwmMM9Fz1zICN54h10SNzory-IrQYFxU0h81sh-r3vSEQcL5jw&scope=email%20profile%20openid%20https://www.googleapis.com/auth/userinfo.email%20https://www.googleapis.com/auth/userinfo.profile&id_token=eyJhbGciOiJSUzI1NiIsImtpZCI6IjkxMWUzOWUyNzkyOGFlOWYxZTlkMWUyMTY0NmRlOTJkMTkzNTFiNDQiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI5ODQ0NzgyNzI1Mi1sMDlzOGhvZnAwZWs2dHNoaG5jNWw5M2U3MWxpNW12di5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbSIsImF1ZCI6Ijk4NDQ3ODI3MjUyLWwwOXM4aG9mcDBlazZ0c2hobmM1bDkzZTcxbGk1bXZ2LmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29tIiwic3ViIjoiMTE0MjU5MDY0MTkxNzkwNjk2ODEzIiwiZW1haWwiOiJtYXJjaGVua28uYWxleGFuZHJAZ21haWwuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImNfaGFzaCI6ImJRdkJnSFB5OGVYOWYwVkxiQ04zMmciLCJub25jZSI6Im5vbmNlIiwibmJmIjoxNjkxMzMxNzM3LCJuYW1lIjoiQWxleGFuZHIgTWFyY2hlbmtvIiwicGljdHVyZSI6Imh0dHBzOi8vbGgzLmdvb2dsZXVzZXJjb250ZW50LmNvbS9hL0FBY0hUdGRZNFdHb2h6TlM1dklob2d4QXlpVG1rRG9zYUtjLTQwVmhUbDVFRjhqbl9qdz1zOTYtYyIsImdpdmVuX25hbWUiOiJBbGV4YW5kciIsImZhbWlseV9uYW1lIjoiTWFyY2hlbmtvIiwibG9jYWxlIjoiZW4iLCJpYXQiOjE2OTEzMzIwMzcsImV4cCI6MTY5MTMzNTYzNywianRpIjoiOGZjOTYxMDczOTRhMDg0ZDU2M2ZhZWU4MzdmOTFjZWNkMzIyYzRlZiJ9.J9dJpPYLoOMDdQtjIVuGUJ-jHBRpaNDCbtKpSnb32zVIeOBMW49l6OzXKPXbRMfRwA8esfErePvbHc0J981JJJZNJWREhvj5d7rZIVestaTWzl4KqaIiu9Qj9HBAlQSU_SVLPEKXkFbMHFEQder7nBbZvKx1C9A8xyKflzpYiffJnrmsYM07UjrCTXZXPVZjfMFr1i1NiSadZgv2gbaM5cHFHsiinIo4jL2umoWMyBdPTYxRw9RIIJasAAj3IwKSOrGgWE_CsTvd_PEbYqNYNL5sYs2NHWqgMfOY8DJJF9rcIYHn1whH0EEqIsYay2KWUA5nfIpol-zyOtQPkAhOqQ&authuser=0&prompt=none

Note how id_token is passed in query string as well

curl -s -X POST "https://oauth2.googleapis.com/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "code=4/0Adeu5BX4aWtmO8bYiaMIUwmMM9Fz1zICN54h10SNzory-IrQYFxU0h81sh-r3vSEQcL5jw" \
  -d "client_id=98447827252-l09s8hofp0ek6tshhnc5l93e71li5mvv.apps.googleusercontent.com" \
  -d "client_secret=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" \
  -d "grant_type=authorization_code" \
  -d "redirect_uri=https%3A%2F%2Fmac-blog.org.ua%2Fcallback"

And exchange endpoint returns the same, so this id token in query string may be used to immediatelly get some basic user info as well as validating access token after exchange

So back to scopes, as you can guess, scopes are used as well to restrict some access, the interesting fact here is that by default in dotnet, for example, the claims them selves are used for authorization, but not scopes

There is pretty good article for dotnet

https://learn.microsoft.com/en-us/azure/active-directory/develop/scenario-protected-web-api-verification-scope-app-roles?tabs=aspnetcore

With important note - protection ensures that the API is called only by:

  • Applications on behalf of users who have the right scopes and roles.
  • Daemon apps that have the right application roles.

Another good article

https://weblog.west-wind.com/posts/2021/Mar/09/Role-based-JWT-Tokens-in-ASPNET-Core

Notes

// Add roles as multiple claims
foreach(var role in user.Roles)
{
	claims.Add(new Claim(ClaimTypes.Role, role.Name));

	// these also work - and reduce token size
	// claims.Add(new Claim("roles", role.Name));
	// claims.Add(new Claim("role", role.Name));
}

all it does - adds "role" string array to JWT token, and can by used by attribute like [Authorize(Roles = "Administrator")]

And finaly from Duende (former Identity Server) docs I see following:

Historically, Duende IdentityServer emitted the scope claims as an array in the JWT. This works very well with the .NET deserialization logic, which turns every array item into a separate claim of type scope.

The newer JWT Profile for OAuth spec mandates that the scope claim is a single space delimited string.

And indeed 2.2.3. Authorization Claims says:

If an authorization request includes a scope parameter, the corresponding issued JWT access token SHOULD include a "scope" claim

So all we need to add to our auth service is the addition of scope parameter to generated access token

also for further experiments we need password grant, so we can generate access tokens by curl and make api calls

const crypto = require('crypto')
const { readFileSync } = require('fs')
const http = require('http')
const https = require('https') // const http2 = require('http2')

// here are our keys that we will use to sign keys, also we will expose public key to the world
const {privateKey, publicKey} = crypto.generateKeyPairSync('rsa', {
  modulusLength: 2048,
  publicKeyEncoding: {type: 'spki',format: 'pem'},
  privateKeyEncoding: {type: 'pkcs8', format: 'pem'}
})

// read private.pem into privateKey variable
// const privateKey = crypto.createPrivateKey(readFileSync('private.pem'))
// const publicKey = crypto.createPrivateKey(readFileSync('public.pem'))

// As described in "2. Client Registration" and "2.3 Client Authentication" we should have at least `client_id` and `client_secret`, also `redirect_uris`, other possible settings will be `enabled`, `allowed_scopes`, etc
const clients = [{
  client_id: '1',
  client_secret: '2',
  redirect_uris: ['https://spa.localhost.direct/callback']
}]

const codes = [{
  // for demo purposes, single code is hardcoded so we may skip authorization and call token directly
  username: 'mac',
  client_id: '1',
  response_type: 'code',
  redirect_uri: 'https://spa.localhost.direct/callback',
  code: '123',
  at: Date.now() - 60
}]

const tokens = []

http.createServer((req, res) => res.setHeader('Location', `https://${req.headers.host}${req.url}`).writeHead(302).end()).listen(80)

https.createServer({
  key: readFileSync('localhost.direct.key'),
  cert: readFileSync('localhost.direct.crt')
}, async (req, res) => {
  const url = new URL(req.url, `https://${req.headers.host}`)
  if (url.pathname === '/.well-known/openid-configuration') {
    return await oidc(req, res)
  }
  else if (url.pathname === '/jwks') {
    return await jwks(req, res)
  }
  else if (url.pathname === '/authorization') {
    return await authorization(req, res)
  }
  else if (url.pathname === '/token') {
    return await token(req, res)
  }
  else {
    console.log(`Not Found: ${req.url}`)
    return res.writeHead(404).end('Not Found\n')
  }
}).listen(443)

// Responsible for interaction with user, display authorization form, consent, etc
async function authorization(req, res) {
  if (req.method !== 'GET' && req.method !== 'POST') {
    console.log(`Method Not Allowed: ${req.method}`)
    return res.writeHead(405).end('Method Not Allowed\n')
  }

  const qs = new URL(req.url, `https://${req.headers.host}`).searchParams
  const response_type = qs.get('response_type')

  if (response_type === 'code') {
    return await authorization_code_grant(req, res)
  }
  else if (response_type === 'token') {
    return await authorization_implicit_grant(req, res)
  }
  // // wont work here, because response_type is passed as post param
  // else if (response_type === 'password') {
  //   return await authorization_password_grant(req, res)
  // }
  else {
    return await authorization_password_grant(req, res) // as a workaround to not bother
    // console.log(`Unsupported response_type: ${response_type}`)
    // return res.setHeader('content-type', 'text/html').writeHead(400).end('<h1>Invalid request</h1><p>Unsupported <code>response_type</code></p>\n')
  }
}

// 4.1. Authorization Code Grant
async function authorization_code_grant(req, res) {
  if (req.method === 'GET') {
    const qs = new URL(req.url, `https://${req.headers.host}`).searchParams
    // TODO: validate wellknown incomming parameters here, if something wrong - warn user and do not redirect him anywhere
    return res.setHeader('content-type', 'text/html').writeHead(200).end(`
      <fieldset>
        <legend>login</legend>
        <form method="POST">
          <table>
            <tr>
              <td>
                <label for="username">username</label>
              </td>
              <td>
                <input type="text" name="username" id="username" required />
              </td>
            </tr>
            <tr>
              <td>
                <label for="password">password</label>
              </td>
              <td>
                <input type="password" name="password" id="password" required />
              </td>
            </tr>
            <tr>
              <td>
              </td>
              <td>
                <input type="hidden" name="csrf" value="TODO: do not forget about CSRF" />
                <input type="submit" value="submit" />
              </td>
            </tr>
          </table>
        </form>
      </fieldset>
    `)
  } else {
    let body = ''
    req.on('data', chunk => body += chunk)
    req.on('end', () => {
      body = new URLSearchParams(body)
      const username = body.get('username')
      // const password = body.get('password')
      const qs = new URL(req.headers.referer).searchParams
      const response_type = qs.get('response_type')
      const client_id = qs.get('client_id')
      const redirect_uri = qs.get('redirect_uri')

      const code = crypto.randomUUID()
      // as described - code should have short lifetime (~10min) and if possible be one time use, also we are saving everything we can about request to validate it in future when exchanging
      codes.push({
        username,
        client_id,
        response_type,
        redirect_uri,
        code,
        scope: qs.get('scope'),
        at: Date.now()
      })

      const url = new URL(redirect_uri)
      if (qs.get('scope')) {
        url.searchParams.set('scope', qs.get('scope'))
      }
      url.searchParams.set('code', code)
      res.setHeader('Location', url).writeHead(302).end()
    })
  }
}

// 4.2. Implicit Grant
async function authorization_implicit_grant(req, res) {
  if (req.method === 'GET') {
    const qs = new URL(req.url, `https://${req.headers.host}`).searchParams
    // TODO: validate wellknown incomming parameters here, if something wrong - warn user and do not redirect him anywhere

    // it is the same as in code flow
    return res.setHeader('content-type', 'text/html').writeHead(200).end(`
      <fieldset>
        <legend>login</legend>
        <form method="POST">
          <table>
            <tr>
              <td>
                <label for="username">username</label>
              </td>
              <td>
                <input type="text" name="username" id="username" required />
              </td>
            </tr>
            <tr>
              <td>
                <label for="password">password</label>
              </td>
              <td>
                <input type="password" name="password" id="password" required />
              </td>
            </tr>
            <tr>
              <td>
              </td>
              <td>
                <input type="hidden" name="csrf" value="TODO: do not forget about CSRF" />
                <input type="submit" value="submit" />
              </td>
            </tr>
          </table>
        </form>
      </fieldset>
    `)
  } else {
    let body = ''
    req.on('data', chunk => body += chunk)
    req.on('end', () => {
      body = new URLSearchParams(body)
      const username = body.get('username')
      // const password = body.get('password')
      const qs = new URL(req.headers.referer).searchParams
      const response_type = qs.get('response_type')
      const client_id = qs.get('client_id')
      const redirect_uri = qs.get('redirect_uri')
      const url = new URL(redirect_uri)

      // the difference here is that we are not storing/dealing with code anymore and goind to redirect to url passinng access token in url fragment
      // token generation copy pasted from token endpoint, later will be extracted, do not bother
      const expires = 3600
      const secret = 'HelloWorld_mac_was_here'
      const header = Buffer.from(JSON.stringify({
        typ: 'JWT',
        alg: 'RS256',
        kid: '1'
      })).toString('base64url')

      const now = Math.floor(Date.now() / 1000)

      const payload = Buffer.from(JSON.stringify({
        sub: username,
        nbf: now - 30,
        iat: now,
        exp: now + expires,
        iss: 'https://auth.localhost.direct',
        aud: client_id,
        scope: qs.get('scope')
      })).toString('base64url')

      const signature = crypto.sign('sha256', Buffer.from(`${header}.${payload}`), privateKey).toString('base64url')

      const fragment = new URLSearchParams()
      fragment.set('access_token', `${header}.${payload}.${signature}`)
      fragment.set('token_type', 'Bearer')
      fragment.set('expires_in', expires)

      url.hash = fragment

      res.setHeader('Location', url).writeHead(302).end()
    })
  }
}

// 4.3. Resource Owner Password Credentials Grant
async function authorization_password_grant(req, res) {
  if (req.method !== 'POST') {
    console.log(`Method Not Allowed: ${req.method}`)
    return res.writeHead(405).end('Method Not Allowed\n')
  }
  // TODO: expected client authentication here, as described in "2.3 Client Authentication" either basic auth header or form fields
  let body = ''
  req.on('data', chunk => body += chunk)
  req.on('end', () => {
    body = new URLSearchParams(body)
    const client_id = body.get('client_id')
    // const client_secret = body.get('client_secret')
    const username = body.get('username')
    // const password = body.get('password')
    const scope = body.get('scope')

    const expires = 3600
    const header = Buffer.from(JSON.stringify({
      typ: 'JWT',
      alg: 'RS256',
      kid: '1'
    })).toString('base64url')

    const now = Math.floor(Date.now() / 1000)

    const payload = Buffer.from(JSON.stringify({
      sub: username,
      nbf: now - 30,
      iat: now,
      exp: now + expires,
      iss: 'https://auth.localhost.direct',
      aud: client_id,
      scope
    })).toString('base64url')

    const signature = crypto.sign('sha256', Buffer.from(`${header}.${payload}`), privateKey).toString('base64url')

    return res.setHeader('content-type', 'application/json').writeHead(200).end(JSON.stringify({
      access_token: `${header}.${payload}.${signature}`,
      token_type: 'Bearer',
      expires_in: expires
    }))
  })
}

async function token(req, res) {
  let body = ''
    req.on('data', chunk => body += chunk)
    req.on('end', () => {
      body = new URLSearchParams(body)
      // const grant_type = body.get('grant_type')
      const redirect_uri = body.get('redirect_uri')
      const client_id = body.get('client_id')
      // const code = body.get('code')
      const code = codes.find(c => c.code === body.get('code'))
      // validate everything, do not forget that code is short lived and should be one time used

      // for simplicity we are using HS256, later it will be RSA and then JWKS, for not to keep things simple
      const expires = 3600
      const header = Buffer.from(JSON.stringify({
        typ: 'JWT',
        alg: 'RS256',
        kid: '1'
      })).toString('base64url')

      const now = Math.floor(Date.now() / 1000)

      const payload = Buffer.from(JSON.stringify({
        sub: code.username,
        nbf: now - 30,
        iat: now,
        exp: now + expires,
        iss: 'https://auth.localhost.direct',
        aud: client_id,
        scope: code.scope
      })).toString('base64url')

      const signature = crypto.sign('sha256', Buffer.from(`${header}.${payload}`), privateKey).toString('base64url')

      return res.setHeader('content-type', 'application/json').writeHead(200).end(JSON.stringify({
        access_token: `${header}.${payload}.${signature}`,
        token_type: 'Bearer',
        expires_in: expires
      }))

    })
}

// 3. OpenID Provider Metadata
// https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
async function oidc(req, res) {
  console.log('OIDC discovery')
  return res.setHeader('content-type', 'application/json').writeHead(200).end(JSON.stringify({
    issuer: 'https://auth.localhost.direct',
    authorization_endpoint: 'https://auth.localhost.direct/authorization',
    token_endpoint: 'https://auth.localhost.direct/token',
    jwks_uri: 'https://auth.localhost.direct/jwks', // TODO: we need to implement this in next step
    response_types_supported: ['code', 'token'], // [ "code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token", "none" ]
    subject_types_supported: ['public'], // [ "public", "pairwise" ]
    id_token_signing_alg_values_supported: ['RS256'], // [ "RS256", "ES256" ]
  }, null, 4))
}

// https://datatracker.ietf.org/doc/html/rfc7517
async function jwks(req, res) {
  console.log('JWKS')
  return res.setHeader('content-type', 'application/json').writeHead(200).end(JSON.stringify({
    keys: [
      // {
      //   kid: '1',
      //   kty: 'RSA',
      //   alg: 'RS256',
      //   use: 'sig',
      //   n: crypto.createPublicKey(publicKey).export({ type: 'pkcs1', format: 'der' }).subarray(28, 28 + 256).toString('base64url'),
      //   e: crypto.createPublicKey(publicKey).export({ type: 'pkcs1', format: 'der' }).subarray(-3).toString('base64url')
      // },
      {
        kid: '1',
        kty: 'RSA',
        alg: 'RS256',
        use: 'sig',
        ...crypto.createPublicKey(publicKey).export({ format: 'jwk' })
      }
  ]
  }, null, 4))
}

Let's pretend that for our comments service we will have following scopes (aka something similar to Azure):

  • Comment.Read - read own comments
  • Comment.Write - write own comments
  • Comment.ReadWrite - read and write own comments, as well as delete
  • Comment.Read.All - read all comments
  • Comment.Write.All - write all comments
  • Comment.ReadWrite.All - read and write own comments, as well as delete

But it seems to be way to many, let's reduce them to

  • comments.read - read only
  • comments.write - basic CRUD for own comments
  • comments.admin - elevated privileges

And here is modified dotnet service

using System.Security.Claims;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Identity.Web;

Microsoft.IdentityModel.Logging.IdentityModelEventSource.ShowPII = true; // for extended logging

/*
mkdir comments
cd comments
dotnet new web --no-https --exclude-launch-settings
dotnet add package Microsoft.Identity.Web
*/
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthorization(options => {
    options.AddPolicy("comments.read", policy => policy.RequireClaim("scope", "comments.read"));
    options.AddPolicy("comments.write", policy => policy.RequireClaim("scope", "comments.write"));
    options.AddPolicy("comments.admin", policy => policy.RequireClaim("scope", "comments.admin"));
}).AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options => {
        options.Authority = "https://auth.localhost.direct";
        options.Audience = "1";

        // for extended logging
        options.Events = new JwtBearerEvents
        {
            OnMessageReceived = context =>
            {
                ILogger logger = context.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("JwtBearer");
                logger.LogDebug("Message received: {0}", context.Token);
                return Task.CompletedTask;
            },
            OnAuthenticationFailed = context =>
            {
                ILogger logger = context.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("JwtBearer");
                logger.LogError("Authentication failed: {0}", context.Exception);
                return Task.CompletedTask;
            },
            OnTokenValidated = context =>
            {
                ILogger logger = context.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("JwtBearer");
                logger.LogDebug("Token validated: {0}", context.SecurityToken);
                return Task.CompletedTask;
            }
        };
    });

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.MapGet("/", (ClaimsPrincipal user) => user.Claims.Select(c => new KeyValuePair<string, string>(c.Type, c.Value)).ToList());

app.MapGet("/comments", () => "returns all comments").RequireAuthorization("comments.read");
app.MapPost("/comments", () => "create comment").RequireAuthorization("comments.write");
app.MapDelete("/comments", () => "delete comment").RequireAuthorization("comments.admin");

app.Run();

And our experiment is

token=$(curl -s -X POST "https://auth.localhost.direct/authorization" -d "response_type=password" -d "client_id=1" -d "client_secret=2" -d "username=mac" -d "password=123" -d "scope=comments.write" | jq -r ".access_token")

curl -s -i -X GET http://localhost:5000/comments -H "Authorization: Bearer $token"

Notes:

  • now our access token has scope claim
  • our comments service has few new endpoints, each requires authentication and can be called only if access token has required scope
  • it requires little bit more checks for cases when there is more than one scope
  • auth service knows the audience and as a result can filter scopes if needed
  • even so it seems little bit harder than roles still it is a way to go because of the specs

In Duende they are doing something like:

namespace IdentityModel.AspNetCore.AccessTokenValidation
{
    /// <summary>
    /// Logic for normalizing scope claims to separate claim types
    /// </summary>
    public static class ScopeConverter
    {
        /// <summary>
        /// Logic for normalizing scope claims to separate claim types
        /// </summary>
        /// <param name="principal"></param>
        /// <returns></returns>
        public static ClaimsPrincipal NormalizeScopeClaims(this ClaimsPrincipal principal)
        {
            var identities = new List<ClaimsIdentity>();

            foreach (var id in principal.Identities)
            {
                var identity = new ClaimsIdentity(id.AuthenticationType, id.NameClaimType, id.RoleClaimType);

                foreach (var claim in id.Claims)
                {
                    if (claim.Type == "scope")
                    {
                        if (claim.Value.Contains(' '))
                        {
                            var scopes = claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries);

                            foreach (var scope in scopes)
                            {
                                identity.AddClaim(new Claim("scope", scope, claim.ValueType, claim.Issuer));
                            }
                        }
                        else
                        {
                            identity.AddClaim(claim);
                        }
                    }
                    else
                    {
                        identity.AddClaim(claim);
                    }
                }

                identities.Add(identity);
            }

            return new ClaimsPrincipal(identities);
        }
    }
}

That will allow use scopes as usual authorization roles, aka [Authorize(Roles = "comments.write")]

There is no need to create a demo for roles claim which definitely will work out of the box in dotnet, also to me it seems that we should stick with scopes, because there are roles claim in dotnet, azure adds groups claims from active directory, some other identity provider may use something own, and scopes are here from spec and absolutely all providers implement them

Refresh Tokens

Now, when scopes are covered we may play with refresh tokens

To recap - OAuth 2.0 does not describe when to return and when not to return refresh token (except the note about implicit flow)

OIDC on the other hand has dedicated offline_access scope for this - link

So as soon as you add this scope in response you will receive desired refresh_token

Let's see how it works in Google

https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=98447827252-l09s8hofp0ek6tshhnc5l93e71li5mvv.apps.googleusercontent.com&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2Fcallback&scope=email%20profile%20offline_access

And it does not work, because Google does not have such scope, we can verify it in discovery document:

https://accounts.google.com/.well-known/openid-configuration

It has only 3 supported scopes: openid, email and profile

Also from Google docs there is a dedicated option access_type=offline

So let's give a try to something else, for example Microsoft one seems to have it:

https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration

Does contain offline_access in scopes

So having sample azure app registration we should be able to ask for refresh token:

https://login.microsoftonline.com/695e64b5-2d13-4ea8-bb11-a6fda2d60c41/oauth2/v2.0/authorize?response_type=code&client_id=1543f1b8-1840-4574-aa44-2e383cdd2652&redirect_uri=http%3A%2F%2Flocalhost&scope=openid%20email%20profile%20offline_access

Notes:

  • request is completely the same as we tried with Google
  • Microsoft complained that openid scope is required, and indeed it is required by oidc spec so I have added it

Redirected to

http://localhost/?code=0.AR8AtWReaRMtqE67Eab9otYMQbjxQxVAGHRFqkQuODzdJlKFAJw.AgABAAIAAAAtyolDObpQQ5VtlI4uGjEPAgDs_wUA9P9_axhpe6eLGXQU4bdMlaQBZdy7S0ZKStzY6JDORF4QDnx5rjfkNu7rE2hmkyTQQ8zQ_gVsvB4V7p3mAgjuymJAj2YOgvZHXIxnoOK_h3PYN9kpdZEjc27EZKC74HtFfjCiG1zBa_vbe9rz6KQiemuKOeuTQrKMAwMsnl7D4maQvtMi_mdYaHlSQrfliZaCbG_zankxv5suj5FG8ZrS6b5XofqwxnUVXfWyQEX2_spEOpt2grlRvcr4oyj3Y0pybhDn3TiqQjDEIxwaEJb7ixk0CGTWLl0JdCHNsTwi1x7oLWj7qukki4HAMNPolKvbicmgu0zswaynuuYg16jINF9uElSMdAzTShmT-k1rfDWWWrFnpB_TCFH3TGb5c3WRM0Iek1ZKtjaNBRn097fJjYk272lfOwTzn8SmbWrL1YAzvkSub6PBDaCoZC59bT2NQmTJuYiFS4lxzzX4Q4Ie6NS2uOjVezjdHKyXueGSXeAP4JvN8XYFLl4vkmKrAqvdzSmoSecDauaVgSw27XjQVnWK2a2lkQNSvthuwbkud9EeNpsEXVb5U1qIn3NZMXgyIzYk9YLpmDSVc5OrAsU-AFh2kpu4UBnENsKaK24_9e9nEC83yWJM1WOWRxIUVIzNYZLJan9s_5ao8jr_0REbaop78YJyiFHBwHkRps3SeTaIfeVdm9LuoNl6aG-uwIzL7HuVGf9E_SSEDQ3XkyUccCT1g-eBShD08Tai&session_state=d977724e-9b34-4cbc-9fea-a1ed601be702#

Lets try to exchange it:

curl -s -X POST -H "Content-Type: application/x-www-form-urlencoded" "https://login.microsoftonline.com/695e64b5-2d13-4ea8-bb11-a6fda2d60c41/oauth2/v2.0/token" -d "code=0.AR8AtWReaRMtqE67Eab9otYMQbjxQxVAGHRFqkQuODzdJlKFAJw.AgABAAIAAAAtyolDObpQQ5VtlI4uGjEPAgDs_wUA9P9_axhpe6eLGXQU4bdMlaQBZdy7S0ZKStzY6JDORF4QDnx5rjfkNu7rE2hmkyTQQ8zQ_gVsvB4V7p3mAgjuymJAj2YOgvZHXIxnoOK_h3PYN9kpdZEjc27EZKC74HtFfjCiG1zBa_vbe9rz6KQiemuKOeuTQrKMAwMsnl7D4maQvtMi_mdYaHlSQrfliZaCbG_zankxv5suj5FG8ZrS6b5XofqwxnUVXfWyQEX2_spEOpt2grlRvcr4oyj3Y0pybhDn3TiqQjDEIxwaEJb7ixk0CGTWLl0JdCHNsTwi1x7oLWj7qukki4HAMNPolKvbicmgu0zswaynuuYg16jINF9uElSMdAzTShmT-k1rfDWWWrFnpB_TCFH3TGb5c3WRM0Iek1ZKtjaNBRn097fJjYk272lfOwTzn8SmbWrL1YAzvkSub6PBDaCoZC59bT2NQmTJuYiFS4lxzzX4Q4Ie6NS2uOjVezjdHKyXueGSXeAP4JvN8XYFLl4vkmKrAqvdzSmoSecDauaVgSw27XjQVnWK2a2lkQNSvthuwbkud9EeNpsEXVb5U1qIn3NZMXgyIzYk9YLpmDSVc5OrAsU-AFh2kpu4UBnENsKaK24_9e9nEC83yWJM1WOWRxIUVIzNYZLJan9s_5ao8jr_0REbaop78YJyiFHBwHkRps3SeTaIfeVdm9LuoNl6aG-uwIzL7HuVGf9E_SSEDQ3XkyUccCT1g-eBShD08Tai&client_id=1543f1b8-1840-4574-aa44-2e383cdd2652&client_secret=2nM8Q~tUEQZypbeJFPMumdh1TVYMORZdebn-ibdA&grant_type=authorization_code&redirect_uri=http://localhost"

and the response (everything worked like a charm, everything is done by spec, just changed identifiers and secrets)

{
  "token_type":"Bearer",
  "scope":"email openid profile",
  "expires_in":5160,
  "ext_expires_in":5160,
  "access_token":"eyJ0eXAiOiJKV1QiLCJub.....",
  "refresh_token":"0.AR8AtWReaRMtqE67Eab....",
  "id_token":"eyJ0eXAiOiJKV1QiLCJhbGciOi...."
}

In response both access_token and id_token are JWT, refresh_token is something else

access_token and id_token have expiration of 1hr

refresh_token has unknown expiration, at least we can only guess or believe the docs

The default lifetime for the refresh tokens is 24 hours for single page apps and 90 days for all other scenarios

Note: in GitHub for example refresh tokens are infinite if I remeber correct and in Slack only 24hr, so it is something that requires additional research, aka in case of possibility to revoke refresh token it should be fine for it to be valid forewer probably but having limitation will require user from time to time to relogin and as a result confirm grant

Accorting to oauth spec I should be able to ask for fresh tokens like so:

curl -s -X POST -H "Content-Type: application/x-www-form-urlencoded" "https://login.microsoftonline.com/695e64b5-2d13-4ea8-bb11-a6fda2d60c41/oauth2/v2.0/token" -d "grant_type=refresh_token&client_id=1543f1b8-1840-4574-aa44-2e383cdd2652&client_secret=2nM8Q~tUEQZypbeJFPMumdh1TVYMORZdebn-ibdA&refresh_token=0.AR8AtWReaRMtqE...."

gives

{
  "token_type":"Bearer",
  "scope":"email openid profile",
  "expires_in":4356,
  "ext_expires_in":4356,
  "access_token":"eyJ0eXAiOiJKV1QiLCJ....",
  "refresh_token":"0.AR8AtWReaRMtqE67Eab9otYMQ...",
  "id_token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJS..."
}

Notes:

  • as described in oauth refresh token may be one time used but in case of Microsoft I can use it more than once
  • response is the same as when we exchanging code
  • in all responses i have cut long string just for readability nothing really secret there, plus all apps, secrets, client_id are deleted before publishing

Refresh tokens and SPA

If we have SPA we should use implicit flow, but implicit flow according to spec should not return refresh token

Then the question is - if token is only for 1hr - what should we do to not ask user rologin each hour?

So lets see what will Microsoft do for implicit flow

https://login.microsoftonline.com/695e64b5-2d13-4ea8-bb11-a6fda2d60c41/oauth2/v2.0/authorize?response_type=token&client_id=1543f1b8-1840-4574-aa44-2e383cdd2652&redirect_uri=http%3A%2F%2Flocalhost&scope=openid%20email%20profile%20offline_access

Note: you will receive unsupported response type until you enable implicit flow in app settings

and redirect will be

http://localhost/#access_token=eyJ0eXAiOiJKV1QiLCJub2...&token_type=Bearer&expires_in=5242&scope=email+openid+profile&session_state=d977724e-9b34-4cbc-9fea-a1ed601be702

as you can see there is no refresh token as expected and given access token has 1h lifetime

from docs there is not clear note

ID tokens and access tokens both expire after a short period of time. Your app must be prepared to refresh these tokens periodically. Implicit flows don't allow you to obtain a refresh token due to security reasons. To refresh either type of token, use the implicit flow in a hidden HTML iframe element. In the authorization request include the prompt=none parameter. To receive a new id_token value, be sure to use response_type=id_token and scope=openid, and a nonce parameter.

There is PKCE approach but alsoe there is good article from oauth0 explaining why it is not ideal

So I have decided to play with OAuth0 little bit more

The fun fact - seying my dashboard and my registrations and experiments made 7 years ago, wondering what have I do back then :)

Created web app with:

  • domain: rua.eu.auth0.com
  • client id: WnRtOqI8xKF4dJCAZQpHWxDc6SrDbyEK
  • client secret: rCVNj95iNJX4yAQr5buLwU9TIICQFymRMleDb1WjzIs8F-blKcOQ4J3S_6MYPNDC
  • discovery: https://rua.eu.auth0.com/.well-known/openid-configuration
  • auth: https://rua.eu.auth0.com/authorize
  • token: https://rua.eu.auth0.com/oauth/token

Request

https://rua.eu.auth0.com/authorize?response_type=token&client_id=WnRtOqI8xKF4dJCAZQpHWxDc6SrDbyEK&redirect_uri=http%3A%2F%2Flocalhost&scope=openid%20offline_access

Redirect

http://localhost/#access_token=eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIiwiaXNzIjoiaHR0cHM6Ly9ydWEuZXUuYXV0aDAuY29tLyJ9..a6CWjTn0bU2NbrBk.VrT0HqmaIkl-WOkYBWYAIEsbthJNX8TTSfcd8_a1A3w-XIAmZE5cjuS2LfX8r5VAPdR0p96avvzjn1PCPL-7WxBkXtLRTpU42bgkt1ky_Vwk7UU45xSs3tgX4mQh2q520Q7c8iwaUEfyGM6s71XQGnMUqCeiaYnQV7b-ZhCSZ7mehr2_e9knhDVogGsBYmKUzupIVnKGXAFd4uVu8DmDd2Saf3Lte7hX8-ZPtf6zoLryRvQjr-Qlgb7fl_a4xB9wx8bwepPYYuvQpzjqCGGkiAlfHz2CVe8tr2cP_PyIdyce.su9-B3dajxeU3WABXXy2Dw&scope=openid%20offline_access&expires_in=7200&token_type=Bearer

Still no refresh token, carefully reading article we should take a note that it works with authorization code flow and PKCE, so lets try first one

https://rua.eu.auth0.com/authorize?response_type=code&client_id=WnRtOqI8xKF4dJCAZQpHWxDc6SrDbyEK&redirect_uri=http%3A%2F%2Flocalhost&scope=openid%20offline_access
http://localhost/?code=JGByuAmdYefsj1QxReB77mxAiVkzKFInffrNAGQlV-ptl
curl -s -X POST -H "Content-Type: application/x-www-form-urlencoded" "https://rua.eu.auth0.com/oauth/token" -d "code=JGByuAmdYefsj1QxReB77mxAiVkzKFInffrNAGQlV-ptl&client_id=WnRtOqI8xKF4dJCAZQpHWxDc6SrDbyEK&client_secret=rCVNj95iNJX4yAQr5buLwU9TIICQFymRMleDb1WjzIs8F-blKcOQ4J3S_6MYPNDC&grant_type=authorization_code&redirect_uri=http://localhost"
{
  "access_token":"eyJhbGciOiJk...",
  "refresh_token":"v1.MRgSDlQ3ThyIq2vrEws16K4eO1n7qt...",
  "id_token":"eyJhbGciOiJSUzI1N....",
  "scope":"openid offline_access",
  "expires_in":86400,
  "token_type":"Bearer"
}

So far it works the same way as Microsoft, now the question whats about frontend side, e.g. my idea was to create small demo, but really fast I have realized that it does not make any sence because to exchange code to access token we need client secret and we should not expose it to front end

So we definitelly need take a closer look at PCKE

Proof Key for Code Exchange (PKCE)

Before proceeding we should make sure that our identity provider supports PKCE by checking its discovery document, here is one from google:

"code_challenge_methods_supported": ["plain", "S256"]

In general it is as simple as:

frontend

create code_verifier - random string, and code_challenge - aka base64urlencode(sha256(code_verifier))

there is cool project PKCE Tools with examples of how it may be done

when forming code grant login url, pass code_challenge as well as all other params

Note: also there is code_challenge_method=S256 parameter that should be passed, depending on implementation it may be plain or SHA256

auth

store retrieved code_challange, redirect user passing authorization code

frontend

exchange received code to access_token the same way as it done in code grant but pass code_verifier instead of client_secret

Something like this:

oauth0rtr-login.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>OAuth0 - Refresh Token Rotation</title>
</head>
<body>
<h1>OAuth0 - Refresh Token Rotation</h1>
<h2>LOGIN</h2>

<form method="get" action="https://rua.eu.auth0.com/authorize">
  <input type="text" name="response_type" value="code" readonly >
  <input type="text" name="client_id" value="WnRtOqI8xKF4dJCAZQpHWxDc6SrDbyEK" readonly >
  <input type="text" name="redirect_uri" value="http://localhost:3000/oauth0rtr-callback.html" readonly >
  <input type="text" name="scope" value="openid offline_access" readonly >
  <input type="text" name="code_challange" value="" readonly >
  <input type="text" name="code_challenge_method" value="S256" readonly >
  <input type="submit" value="Login">
</form>

<script>
  function sha256(text) {
    return crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
  }

  function base64urlencode(a) {
    return btoa(String.fromCharCode.apply(null, new Uint8Array(a))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
  }

  const code_verifier = crypto.randomUUID();
  localStorage.setItem('code_verifier', code_verifier); // will be used later, in callback, to exchange received code to acces token

  sha256(code_verifier).then(base64urlencode).then(code_challange => {
    document.querySelector('input[name="code_challange"]').value = code_challange;
  });
</script>

</body>
</html>

oauth0rtr-callback.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>OAuth0 - Refresh Token Rotation</title>
</head>
<body>
<h1>OAuth0 - Refresh Token Rotation</h1>
<h2>CALLBACK</h2>

<input type="text" name="code" id="code" readonly>
<input type="text" name="code_verifier" id="code_verifier" readonly>

<pre><code id="res"></code></pre>

<script>
  document.getElementById('code_verifier').value = localStorage.getItem('code_verifier'); // retrieve code_verifier created on login stage

  const qs = new URLSearchParams(window.location.search);
  document.getElementById('code').value = qs.get('code');

  fetch('https://rua.eu.auth0.com/oauth/token', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded'
    },
    body: new URLSearchParams({
        grant_type: 'authorization_code',
        code: qs.get('code'),
        client_id: 'WnRtOqI8xKF4dJCAZQpHWxDc6SrDbyEK',
        code_verifier: localStorage.getItem('code_verifier'), // instead of client_secret
        redirect_uri: 'http://localhost:3000/oauth0rtr-callback.html',
    })
  })
    .then(res => res.json())
    .then(data => {
      console.log(data);
      document.getElementById('res').innerHTML = JSON.stringify(data, null, 2);
    })
</script>

</body>
</html>

And we have our desirec refresh token in our frontend app without the need to have any backend servers

Take a closer look for implementation details, especially description of how refresh tokens are invalidated, they should be one time used, also their lifetime becomes less each time they are refreshed

So lets implement it by adding code_challenge_methods_supported to our discovery as well as some dummy implementation

const crypto = require('crypto')
const { readFileSync } = require('fs')
const http = require('http')
const https = require('https') // const http2 = require('http2')

// here are our keys that we will use to sign keys, also we will expose public key to the world
const {privateKey, publicKey} = crypto.generateKeyPairSync('rsa', {
  modulusLength: 2048,
  publicKeyEncoding: {type: 'spki',format: 'pem'},
  privateKeyEncoding: {type: 'pkcs8', format: 'pem'}
})

// read private.pem into privateKey variable
// const privateKey = crypto.createPrivateKey(readFileSync('private.pem'))
// const publicKey = crypto.createPrivateKey(readFileSync('public.pem'))

// As described in "2. Client Registration" and "2.3 Client Authentication" we should have at least `client_id` and `client_secret`, also `redirect_uris`, other possible settings will be `enabled`, `allowed_scopes`, etc
const clients = [{
  client_id: '1',
  client_secret: '2',
  redirect_uris: ['https://spa.localhost.direct/callback']
}]

const codes = [{
  // for demo purposes, single code is hardcoded so we may skip authorization and call token directly
  username: 'mac',
  client_id: '1',
  response_type: 'code',
  redirect_uri: 'https://spa.localhost.direct/callback',
  code: '123',
  at: Date.now() - 60
}]

const tokens = []

const challanges = []

http.createServer((req, res) => res.setHeader('Location', `https://${req.headers.host}${req.url}`).writeHead(302).end()).listen(80)

https.createServer({
  key: readFileSync('localhost.direct.key'),
  cert: readFileSync('localhost.direct.crt')
}, async (req, res) => {
  const url = new URL(req.url, `https://${req.headers.host}`)
  if (url.pathname === '/.well-known/openid-configuration') {
    return await oidc(req, res)
  }
  else if (url.pathname === '/jwks') {
    return await jwks(req, res)
  }
  else if (url.pathname === '/authorization') {
    return await authorization(req, res)
  }
  else if (url.pathname === '/token') {
    return await token(req, res)
  }
  else {
    console.log(`Not Found: ${req.url}`)
    return res.writeHead(404).end('Not Found\n')
  }
}).listen(443)

// Responsible for interaction with user, display authorization form, consent, etc
async function authorization(req, res) {
  if (req.method !== 'GET' && req.method !== 'POST') {
    console.log(`Method Not Allowed: ${req.method}`)
    return res.writeHead(405).end('Method Not Allowed\n')
  }

  const qs = new URL(req.url, `https://${req.headers.host}`).searchParams
  const response_type = qs.get('response_type')

  if (response_type === 'code') {
    return await authorization_code_grant(req, res)
  }
  else if (response_type === 'token') {
    return await authorization_implicit_grant(req, res)
  }
  // // wont work here, because response_type is passed as post param
  // else if (response_type === 'password') {
  //   return await authorization_password_grant(req, res)
  // }
  else {
    return await authorization_password_grant(req, res) // as a workaround to not bother
    // console.log(`Unsupported response_type: ${response_type}`)
    // return res.setHeader('content-type', 'text/html').writeHead(400).end('<h1>Invalid request</h1><p>Unsupported <code>response_type</code></p>\n')
  }
}

// 4.1. Authorization Code Grant
async function authorization_code_grant(req, res) {
  if (req.method === 'GET') {
    const qs = new URL(req.url, `https://${req.headers.host}`).searchParams
    // TODO: validate wellknown incomming parameters here, if something wrong - warn user and do not redirect him anywhere
    return res.setHeader('content-type', 'text/html').writeHead(200).end(`
      <fieldset>
        <legend>login</legend>
        <form method="POST">
          <table>
            <tr>
              <td>
                <label for="username">username</label>
              </td>
              <td>
                <input type="text" name="username" id="username" required />
              </td>
            </tr>
            <tr>
              <td>
                <label for="password">password</label>
              </td>
              <td>
                <input type="password" name="password" id="password" required />
              </td>
            </tr>
            <tr>
              <td>
              </td>
              <td>
                <input type="hidden" name="csrf" value="TODO: do not forget about CSRF" />
                <input type="submit" value="submit" />
              </td>
            </tr>
          </table>
        </form>
      </fieldset>
    `)
  } else {
    let body = ''
    req.on('data', chunk => body += chunk)
    req.on('end', () => {
      body = new URLSearchParams(body)
      const username = body.get('username')
      // const password = body.get('password')
      const qs = new URL(req.headers.referer).searchParams
      const response_type = qs.get('response_type')
      const client_id = qs.get('client_id')
      const redirect_uri = qs.get('redirect_uri')

      const code = crypto.randomUUID()
      // as described - code should have short lifetime (~10min) and if possible be one time use, also we are saving everything we can about request to validate it in future when exchanging
      codes.push({
        username,
        client_id,
        response_type,
        redirect_uri,
        code,
        scope: qs.get('scope'),
        at: Date.now()
      })

      if (qs.get('code_challenge')) {
        challanges.push({
          code,
          code_challenge: qs.get('code_challenge'),
          code_challenge_method: qs.get('code_challenge_method')
          // pass other wanted data here, will be used later, when exchanging code to token
        })
      }

      const url = new URL(redirect_uri)
      if (qs.get('scope')) {
        url.searchParams.set('scope', qs.get('scope'))
      }
      url.searchParams.set('code', code)
      res.setHeader('Location', url).writeHead(302).end()
    })
  }
}

// 4.2. Implicit Grant
async function authorization_implicit_grant(req, res) {
  if (req.method === 'GET') {
    const qs = new URL(req.url, `https://${req.headers.host}`).searchParams
    // TODO: validate wellknown incomming parameters here, if something wrong - warn user and do not redirect him anywhere

    // it is the same as in code flow
    return res.setHeader('content-type', 'text/html').writeHead(200).end(`
      <fieldset>
        <legend>login</legend>
        <form method="POST">
          <table>
            <tr>
              <td>
                <label for="username">username</label>
              </td>
              <td>
                <input type="text" name="username" id="username" required />
              </td>
            </tr>
            <tr>
              <td>
                <label for="password">password</label>
              </td>
              <td>
                <input type="password" name="password" id="password" required />
              </td>
            </tr>
            <tr>
              <td>
              </td>
              <td>
                <input type="hidden" name="csrf" value="TODO: do not forget about CSRF" />
                <input type="submit" value="submit" />
              </td>
            </tr>
          </table>
        </form>
      </fieldset>
    `)
  } else {
    let body = ''
    req.on('data', chunk => body += chunk)
    req.on('end', () => {
      body = new URLSearchParams(body)
      const username = body.get('username')
      // const password = body.get('password')
      const qs = new URL(req.headers.referer).searchParams
      const response_type = qs.get('response_type')
      const client_id = qs.get('client_id')
      const redirect_uri = qs.get('redirect_uri')
      const url = new URL(redirect_uri)

      // the difference here is that we are not storing/dealing with code anymore and goind to redirect to url passinng access token in url fragment
      // token generation copy pasted from token endpoint, later will be extracted, do not bother
      const expires = 3600
      const secret = 'HelloWorld_mac_was_here'
      const header = Buffer.from(JSON.stringify({
        typ: 'JWT',
        alg: 'RS256',
        kid: '1'
      })).toString('base64url')

      const now = Math.floor(Date.now() / 1000)

      const payload = Buffer.from(JSON.stringify({
        sub: username,
        nbf: now - 30,
        iat: now,
        exp: now + expires,
        iss: 'https://auth.localhost.direct',
        aud: client_id,
        scope: qs.get('scope')
      })).toString('base64url')

      const signature = crypto.sign('sha256', Buffer.from(`${header}.${payload}`), privateKey).toString('base64url')

      const fragment = new URLSearchParams()
      fragment.set('access_token', `${header}.${payload}.${signature}`)
      fragment.set('token_type', 'Bearer')
      fragment.set('expires_in', expires)

      url.hash = fragment

      res.setHeader('Location', url).writeHead(302).end()
    })
  }
}

// 4.3. Resource Owner Password Credentials Grant
async function authorization_password_grant(req, res) {
  if (req.method !== 'POST') {
    console.log(`Method Not Allowed: ${req.method}`)
    return res.writeHead(405).end('Method Not Allowed\n')
  }
  // TODO: expected client authentication here, as described in "2.3 Client Authentication" either basic auth header or form fields
  let body = ''
  req.on('data', chunk => body += chunk)
  req.on('end', () => {
    body = new URLSearchParams(body)
    const client_id = body.get('client_id')
    // const client_secret = body.get('client_secret')
    const username = body.get('username')
    // const password = body.get('password')
    const scope = body.get('scope')

    const expires = 3600
    const header = Buffer.from(JSON.stringify({
      typ: 'JWT',
      alg: 'RS256',
      kid: '1'
    })).toString('base64url')

    const now = Math.floor(Date.now() / 1000)

    const payload = Buffer.from(JSON.stringify({
      sub: username,
      nbf: now - 30,
      iat: now,
      exp: now + expires,
      iss: 'https://auth.localhost.direct',
      aud: client_id,
      scope
    })).toString('base64url')

    const signature = crypto.sign('sha256', Buffer.from(`${header}.${payload}`), privateKey).toString('base64url')

    return res.setHeader('content-type', 'application/json').writeHead(200).end(JSON.stringify({
      access_token: `${header}.${payload}.${signature}`,
      token_type: 'Bearer',
      expires_in: expires
    }))
  })
}

async function token(req, res) {
  let body = ''
    req.on('data', chunk => body += chunk)
    req.on('end', () => {
      body = new URLSearchParams(body)
      // const grant_type = body.get('grant_type')
      const redirect_uri = body.get('redirect_uri')
      const client_id = body.get('client_id')
      // const code = body.get('code')
      const code = codes.find(c => c.code === body.get('code'))
      // validate everything, do not forget that code is short lived and should be one time used

      const challange = challanges.find(c => c.code === code.code)
      // PKCE - we should check either client_secret or challange

      // for simplicity we are using HS256, later it will be RSA and then JWKS, for not to keep things simple
      const expires = 3600
      const header = Buffer.from(JSON.stringify({
        typ: 'JWT',
        alg: 'RS256',
        kid: '1'
      })).toString('base64url')

      const now = Math.floor(Date.now() / 1000)

      const payload = Buffer.from(JSON.stringify({
        sub: code.username,
        nbf: now - 30,
        iat: now,
        exp: now + expires,
        iss: 'https://auth.localhost.direct',
        aud: client_id,
        scope: code.scope
      })).toString('base64url')

      const signature = crypto.sign('sha256', Buffer.from(`${header}.${payload}`), privateKey).toString('base64url')

      return res.setHeader('content-type', 'application/json').writeHead(200).end(JSON.stringify({
        access_token: `${header}.${payload}.${signature}`,
        token_type: 'Bearer',
        expires_in: expires
      }))

    })
}

// 3. OpenID Provider Metadata
// https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
async function oidc(req, res) {
  console.log('OIDC discovery')
  return res.setHeader('content-type', 'application/json').writeHead(200).end(JSON.stringify({
    issuer: 'https://auth.localhost.direct',
    authorization_endpoint: 'https://auth.localhost.direct/authorization',
    token_endpoint: 'https://auth.localhost.direct/token',
    jwks_uri: 'https://auth.localhost.direct/jwks', // TODO: we need to implement this in next step
    response_types_supported: ['code', 'token'], // [ "code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token", "none" ]
    subject_types_supported: ['public'], // [ "public", "pairwise" ]
    id_token_signing_alg_values_supported: ['RS256'], // [ "RS256", "ES256" ]
    token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"], // TODO: depending on this client may decide whether to use basic auth or pass client_id and client_secret as port params
    code_challenge_methods_supported: ["plain", "S256"] // PCKE: supported methods, usually it will be S256 - SHA, if decide to leave plain need demo for it
  }, null, 4))
}

// https://datatracker.ietf.org/doc/html/rfc7517
async function jwks(req, res) {
  console.log('JWKS')
  return res.setHeader('content-type', 'application/json').writeHead(200).end(JSON.stringify({
    keys: [
      // {
      //   kid: '1',
      //   kty: 'RSA',
      //   alg: 'RS256',
      //   use: 'sig',
      //   n: crypto.createPublicKey(publicKey).export({ type: 'pkcs1', format: 'der' }).subarray(28, 28 + 256).toString('base64url'),
      //   e: crypto.createPublicKey(publicKey).export({ type: 'pkcs1', format: 'der' }).subarray(-3).toString('base64url')
      // },
      {
        kid: '1',
        kty: 'RSA',
        alg: 'RS256',
        use: 'sig',
        ...crypto.createPublicKey(publicKey).export({ format: 'jwk' })
      }
  ]
  }, null, 4))
}

Service to Service auth

Think of this as a way to get rid of ApiKeys or other kinds of shared secrets, here is an example:

  • we have sms api - small service that is used to send sms to the clients
  • it is ment for internal usage only (aka only our other services are making calls to it)
  • usually you will add some kind of ApiKey/ApiSecret/callitwhatever to protect this service
  • this secret then is shared accross all clients
  • this is bad because now, in case of need it will become hard to revoke/rotate it

Instead we are going to use client credentials flow, when our service, before making a call to sms api will receive its signed access token from our auth service, then it will make an call to sms service, and because of discovery and jwks sms service will be able to varify it

It is as simple as implementing "4.4.1. Client Credentials Grant"

POST /token HTTP/1.1
Host: server.example.com
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials

Do not even bother for now, it will work because of all previous demos

Service to service - onbehalf user

There is literally no wellknown way to do it

We should either stick with client credentials but then it is not onbehalf user calls and things like ownership validation need to be modifier - e.g. check ownership or client scope

Or use supported apprpoach where user interaction is required - but it sounds little bit strange in cases when all services are internal and trusted

In Microsoft realisation there is so called Microsoft identity platform and OAuth 2.0 On-Behalf-Of flow

It is little bit different from what we want but really close to it

Here are request parameters for token endpoint:

  • grant_type - Required, must be urn:ietf:params:oauth:grant-type:jwt-bearer
  • client_id, client_secret - Required, client credentials
  • assertion - Required, access token received from user, its aud should be current client
  • scope - required
  • request_token_use - required, in this flow value must be on_behalf_of

Example:

POST /oauth2/v2.0/token HTTP/1.1
Host: login.microsoftonline.com/<tenant>
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
client_id=535fb089-9ff3-47b6-9bfb-4f1264799865
client_secret=sampleCredentia1s
assertion=eyJ0eXAiOiJKV....
scope=https://graph.microsoft.com/user.read+offline_access
requested_token_use=on_behalf_of

In response there will be:

  • token_type - usuall Bearer
  • scope - granted scopes
  • expires_in - lifetime seconds
  • access_token - our jwt
  • refresh_token - only provided if offline_access scope was requested

So it seems that in our case, our token endpoint should be flexible and return access tokens for wellknown clients event without assertions or something like that, of course it will be less secure but if we want full security we should not do such things at all and do it the same way as it is supposed to be for 3rd party clients with refresh token and access code grant

dotnet openid connect auth service blueprint

Here is an really dummy and silly starting point for an auth service

using System.IdentityModel.Tokens.Jwt;
using System.Net;
using System.Net.Mime;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Web;

using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;

/*
dotnet add package Microsoft.IdentityModel.Protocols.OpenIdConnect

Microsoft.IdentityModel.Protocols.OpenIdConnect enums like OpenIdConnectGrantTypes.RefreshToken https://learn.microsoft.com/en-us/dotnet/api/microsoft.identitymodel.protocols.openidconnect?view=msal-web-dotnet-latest
System.IdentityModel.Tokens.Jwt enums like JwtConstants.TokenType
*/

var rsa = RSA.Create();
rsa.ImportFromPem(File.ReadAllText("../../private.pem")); // "openssl genrsa -out private.pem 2048" and optionally "openssl rsa -in private.pem -pubout -out public.pem"

var jwk = JsonWebKeyConverter.ConvertFromRSASecurityKey(new RsaSecurityKey(rsa));
jwk.Kid = "mykey";
jwk.Alg = SecurityAlgorithms.RsaSha256;
jwk.Use = JsonWebKeyUseNames.Sig;

// var jwks = new JsonWebKeySet();
// jwks.Keys.Add(jwk);


var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.MapGet("/.well-known/openid-configuration", (HttpRequest req) => new
{
    // https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
    issuer = $"{req.Scheme}://{req.Host}",
    authorization_endpoint = $"{req.Scheme}://{req.Host}/authorization",
    token_endpoint = $"{req.Scheme}://{req.Host}/token",
    jwks_uri = $"{req.Scheme}://{req.Host}/jwks",
    response_types_supported = new[] { OpenIdConnectResponseType.Code, OpenIdConnectResponseType.Token },
    subject_types_supported = new[] { "public" },
    id_token_signing_alg_values_supported = new[] { SecurityAlgorithms.RsaSha256 },
    code_challenge_methods_supported = new[] { "plain", "S256" }
});

// app.MapGet("/jwks", () => jwks); // TODO: returns way to many data, including nullish values
app.MapGet("/jwks", () => new
{
    keys = new[]
    {
        new
        {
            kid = jwk.Kid,
            kty = jwk.Kty,
            alg = jwk.Alg,
            use = jwk.Use,
            n = jwk.N,
            e = jwk.E
        }
    }
});

/*
http://localhost:5000/authorization?response_type=code&client_id=client1&redirect_uri=http://localhost
*/
app.MapGet("/authorization", (HttpRequest request, HttpResponse response) =>
{
    response.ContentType = MediaTypeNames.Text.Html;

    // just for demo
    if (!request.Query.TryGetValue("response_type", out var responseType))
    {
        return response.WriteAsync("<h1>response type is required</h1>");
    }

    if (responseType != "code" && responseType != "token")
    {
        return response.WriteAsync("<h1>unknown response type</h1>");
    }
    
    return response.WriteAsync("""
                               <fieldset>
                               <legend>login</legend>
                               <form method="POST">
                               <table>
                               <tr><td><label for="username">username</label></td><td><input type="text" id="username" name="username" required /></td></tr>
                               <tr><td><label for="password">password</label></td><td><input type="password" name="password" id="password" required /></td></tr>
                               <tr><td></td><td><input type="hidden" name="csrf" value="TODO: do not forget about CSRF" /><input type="submit" value="submit" /></td></tr>
                               </table>
                               </form>
                               </fieldset>
                               """);
});

app.MapPost("/authorization", ([FromQuery(Name = "redirect_uri")]string redirectUri, HttpResponse response) =>
{
    var uri = new UriBuilder(redirectUri);
    var query = HttpUtility.ParseQueryString(uri.Query);
    query.Set("code", "123");
    uri.Query = query.ToString();
    
    response.Redirect(uri.ToString());
});

/*
curl -X POST localhost:5000/token
*/
app.MapPost("/token", (HttpRequest req) =>
{
    var oneHour = 3600;
    var now = DateTime.Now;
    var issuer = $"{req.Scheme}://{req.Host}";
    var audience = "comments";
    var claims = new[] { new Claim(JwtRegisteredClaimNames.Sub, "user1") };
    var notBefore = now.AddSeconds(-10);
    var expires = now.AddSeconds(oneHour);
    var signingCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256);
    var jwt = new JwtSecurityToken(issuer, audience, claims, notBefore, expires, signingCredentials);
    return new { access_token = new JwtSecurityTokenHandler().WriteToken(jwt), token_type = "Bearer", expires_in = oneHour };
});

app.Run();

And sample resource service

using System.Net;
using System.Security.Claims;

using Microsoft.AspNetCore.Authentication.JwtBearer;

var builder = WebApplication.CreateBuilder(args);

builder.WebHost.ConfigureKestrel((context, serverOptions) => serverOptions.Listen(IPAddress.Any, 8000, listenOptions => listenOptions.UseConnectionLogging()));

// dotnet add package Microsoft.Identity.Web
builder.Services
    .AddAuthorization(options =>
    {
        options.AddPolicy("comments.read", policy => policy.RequireClaim("scope", "comments.read"));
        options.AddPolicy("comments.write", policy => policy.RequireClaim("scope", "comments.write"));
        options.AddPolicy("comments.admin", policy => policy.RequireClaim("scope", "comments.admin"));
    })
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = "http://localhost:5000";
        options.Audience = "comments";
        options.RequireHttpsMetadata = false; // for demo
    });

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

/*
curl -s localhost:8000 -H "Authorization: Bearer $(curl -s -X POST localhost:5000/token | jq -r '.access_token')" | jq
*/
app.MapGet("/", (ClaimsPrincipal user) => user.Claims.Select(c => new KeyValuePair<string, string>(c.Type, c.Value)).ToList());

app.MapGet("/comments", () => "returns all comments").RequireAuthorization("comments.read");
app.MapPost("/comments", () => "create comment").RequireAuthorization("comments.write");
app.MapDelete("/comments", () => "delete comment").RequireAuthorization("comments.admin");

app.Run();

By intent there is no checks, validations and so on and so on, just to keep it small enough

Also from what I see, packages like OpenIdConfiguration are ment to be used in resource services rather than auth services, so I wonder how it will look like to have auth service without nuget dependencies at all

And it was as easy as

using System.Net.Mime;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Web;

using Microsoft.AspNetCore.Mvc;

var rsa = RSA.Create();
rsa.ImportFromPem(File.ReadAllText("../../private.pem")); // "openssl genrsa -out private.pem 2048" and optionally "openssl rsa -in private.pem -pubout -out public.pem"

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.MapGet("/.well-known/openid-configuration", (HttpRequest req) => new
{
    // https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
    issuer = $"{req.Scheme}://{req.Host}",
    authorization_endpoint = $"{req.Scheme}://{req.Host}/authorization",
    token_endpoint = $"{req.Scheme}://{req.Host}/token",
    jwks_uri = $"{req.Scheme}://{req.Host}/jwks",
    response_types_supported = new[] { "code", "token" },
    subject_types_supported = new[] { "public" },
    id_token_signing_alg_values_supported = new[] { "RS256" },
    code_challenge_methods_supported = new[] { "plain", "S256" }
});

app.MapGet("/jwks", () => new
{
    keys = new[]
    {
        new
        {
            kid = "jwk1",
            kty = "RSA",
            alg = "RS256",
            use = "sig",
            n = Convert.ToBase64String(rsa.ExportParameters(false).Modulus!).TrimEnd('=').Replace('+', '-').Replace('/', '_'),
            e = Convert.ToBase64String(rsa.ExportParameters(false).Exponent!).TrimEnd('=').Replace('+', '-').Replace('/', '_')
        }
    }
});

/*
http://localhost:5000/authorization?response_type=code&client_id=client1&redirect_uri=http://localhost
*/
app.MapGet("/authorization", ([FromQuery(Name = "response_type")]string responseType, HttpResponse response) =>
{
    response.ContentType = MediaTypeNames.Text.Html;

    // just for demo
    if (!string.Equals("code", responseType) && !string.Equals("token", responseType))
    {
        return response.WriteAsync("<h1>response type is missing or malformed</h1>");
    }

    return response.WriteAsync("""
                               <fieldset>
                               <legend>login</legend>
                               <form method="POST">
                               <table>
                               <tr><td><label for="username">username</label></td><td><input type="text" id="username" name="username" required /></td></tr>
                               <tr><td><label for="password">password</label></td><td><input type="password" name="password" id="password" required /></td></tr>
                               <tr><td></td><td><input type="hidden" name="csrf" value="TODO: do not forget about CSRF" /><input type="submit" value="submit" /></td></tr>
                               </table>
                               </form>
                               </fieldset>
                               """);
});

app.MapPost("/authorization", ([FromQuery(Name = "redirect_uri")]string redirectUri, HttpResponse response) =>
{
    var uri = new UriBuilder(redirectUri);
    var query = HttpUtility.ParseQueryString(uri.Query);
    query.Set("code", "123");
    uri.Query = query.ToString();
    
    response.Redirect(uri.ToString());
});

/*
curl -X POST localhost:5000/token
*/
app.MapPost("/token", (HttpRequest req) =>
{
    var expiration = 3600;
    var header = Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new
    {
        typ = "JWT",
        alg = "RS256", 
        kid = "key1"
    }))).TrimEnd('=').Replace('+', '-').Replace('/', '_');
    
    var payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new
    {
        sub = "user1",
        nbf = DateTimeOffset.Now.AddSeconds(-10).ToUnixTimeSeconds(),
        iat = DateTimeOffset.Now.ToUnixTimeSeconds(),
        exp = DateTimeOffset.Now.AddSeconds(expiration).ToUnixTimeSeconds(),
        iss = $"{req.Scheme}://{req.Host}",
        aud = "comments"
    }))).TrimEnd('=').Replace('+', '-').Replace('/', '_');

    var signature = Convert.ToBase64String(rsa.SignData(Encoding.UTF8.GetBytes($"{header}.{payload}"), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
    
    return new { access_token = $"{header}.{payload}.{signature}", token_type = "Bearer", expires_in = expiration };
});

app.Run();

Note: not pretending to say I started to understand how it all works together, but having such deep dive samples definitely helps, also with such approach there is nothing to update in future except dotnet itself which, theoretically will make it much safer, but on the other hand will definitelly require understanding of all this crypto things like RSA paddings, etc, here i have some notes around this topic