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
andclient_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
toaccess_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)
Mobile apps (Android, iOS)
Client apps (SPA)
Devices (TV, XBOX)
Service Accounts
- client registration: https://console.cloud.google.com/apis/credentials?project=your-project-id
- discovery: https://accounts.google.com/.well-known/openid-configuration
- authorization: https://accounts.google.com/o/oauth2/v2/auth
- token: https://oauth2.googleapis.com/token
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 topem
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 servicex5t
- 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 likekey1
- 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
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 havesub
,iss
,aud
,exp
,iat
, andat_hash
. The last one used to verify id tokenprofile
- optional,name
,family_name
,given_name
,middle_name
,nickname
,preferred_username
,profile
,picture
,website
,gender
,birthdate
,zoneinfo
,locale
, andupdated_at
.email
- optional,email
andemail_verified
address
- optional,address
phone
- optional,phone_number
andphone_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?
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
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 commentsComment.Write
- write own commentsComment.ReadWrite
- read and write own comments, as well as deleteComment.Read.All
- read all commentsComment.Write.All
- write all commentsComment.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 onlycomments.write
- basic CRUD for own commentscomments.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 newid_token
value, be sure to useresponse_type=id_token
andscope=openid
, and anonce
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)
- https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps-07
- https://datatracker.ietf.org/doc/html/rfc7636
- https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow-with-proof-key-for-code-exchange-pkce
- https://auth0.com/docs/get-started/authentication-and-authorization-flow/call-your-api-using-the-authorization-code-flow-with-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 beurn:ietf:params:oauth:grant-type:jwt-bearer
client_id
,client_secret
- Required, client credentialsassertion
- Required, access token received from user, itsaud
should be current clientscope
- requiredrequest_token_use
- required, in this flow value must beon_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
- usuallBearer
scope
- granted scopesexpires_in
- lifetime secondsaccess_token
- our jwtrefresh_token
- only provided ifoffline_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