OpenID Connect client guide
Goal
Create a web application that allows users to authenticate using their credentials in Keycloak.
Project setup
You don’t need any client side scripting to solve the assignment. Actually I would recommend doing without, because it just adds complexity.
.NET/C
Open a terminal/cmd/powershell
Navigate to the folder you want to contain the project files.
You can use cd <foldername>
to navigate and mkdir <foldername>
to create a
new folder.
Then bootstrap the project using:
dotnet new mvc
Python
For Python I will recommend Flask, unless you are already accustomed to a different framework.
Node/Express
Open a terminal/cmd/powershell
Navigate to the folder you want to contain the project files.
You can use cd <foldername>
to navigate and mkdir <foldername>
to create a
new folder.
npm init --yes
npm install express express-handlebars node-fetch
npm install --save-dev typescript concurrently nodemon @types/express @types/node @types/express-handlebars @types/node-fetch
npx tsc --init
Add following to tsconfig.json
{
"outDir": "./dist"
}
And include following to package.json
{
"scripts": {
"build": "npx tsc",
"start": "node dist/index.js",
"dev": "concurrently \"npx tsc --watch\" \"nodemon -q dist/index.js\""
}
}
Create a index.ts
file with something like:
import express from "express";
import { engine } from "express-handlebars"; //Sets our app to use the handlebars engine
const app = express();
app.engine("handlebars", engine());
app.set("view engine", "handlebars");
app.set("views", "./views");
app.get("/", (req, res) => {
res.render("home", { placeholder: "world" });
});
const port = 3000;
app.listen(port, () =>
console.log(`App listening to port http://localhost:${port}`),
);
Create views/layouts/main.handlebars
And views/home.handlebars
Endpoints
Your application needs the following endpoints. If you use different paths for your endpoints you need to adjust accordingly.
Path | Name | Purpose |
---|---|---|
/ | Home | Landing page with login link |
/login | Login | Redirects to Keycloak |
/callback | Callback | Exchange code for tokens |
Home and Callback (redirect URI) should be added to the client settings in Keycloak admin
All endpoints in Keycloak can be found at http://localhost:8080/realms/master/.well-known/openid-configuration
For the rest of the document, keycloak endpoints are referred to by the property name in
openid-configuration document. Also the document itself is referred to as config
.
Example: config.authorization_endpoint
Login button
Add the following HTML to the view for you Home endpoint:
<a href="/login">Login</a>
Authorization request
The Login endpoint should redirect the browser to a URL similar to the following:
http://localhost:8080/realms/master/protocol/openid-connect/auth?client_id=client_id&scope=openid email phone address profile&response_type=code&redirect_uri=http://localhost:5138/Home/Callback&prompt=login&state=97tvtZHsHTV4I5parGxBJ-sRF5Lml_JGmb21VXwtoaE&code_challenge_method=plain&code_challenge=es1kPi2mRaxvo4Y3cb8gRFRmYrpJzyO9FelyjMrgy0w
Value | Description |
---|---|
clientId | Client ID for the client you created in Keycloak |
callback | Your endpoint that handles callback |
state | Long random string that you store in cache |
codeVerifier | Long random string that you store in cache |
Note code_challenge/code_verifier are part of the PKCE extension to OAuth 2.0
For generating the random strings you should use a Cryptographically secure pseudorandom number generator (CSPRNG).
C-Sharp (C#)
var parameters = new Dictionary<string, string?>
{
{ "client_id", clientId },
{ "scope", "openid email phone address profile" },
{ "response_type", "code" },
{ "redirect_uri", callback },
{ "prompt", "login" },
{ "state", state },
{ "code_challenge_method", "plain" },
{ "code_challenge", codeVerifier }
};
var authorizationUri = QueryHelpers.AddQueryString(config.authorization_endpoint, parameters);
Python
parameters = {
"client_id": client_id,
"scope": "openid email phone address profile",
"response_type": "code",
"redirect_uri": redirect_uri,
"prompt": "login",
"state": state,
"code_challenge_method": "S256",
"code_challenge": create_challenge(code_verifier)
}
redirect_url = f"{authorization_endpoint}?{urllib.parse.urlencode(parameters)}"
TypeScript
const parameters = {
client_id: clientId,
scope: "openid email phone address profile",
response_type: "code",
redirect_uri: callback,
prompt: "login",
state: state,
code_challenge_method: "plain",
code_challenge: codeVerifier,
};
const authorizationUri = `${config.authorization_endpoint}?${new URLSearchParams(parameters)}`;
Caching
You need to code_verifier
because you need it to verify the callback.
Use state
as key in your cache.
In a real app you would use something like database or redis for cache. However here we simplify a bit and just use an in-memory collection instead.
C-Sharp (C#)
private static readonly Dictionary<string, string> _cache = new();
Alternatively you can use
HttpContext.Session
But you will need to add this to Program.cs
first
// Below builder.Services.AddControllersWithViews();
builder.Services.AddSession(options =>
{
options.Cookie.HttpOnly = true;
options.Cookie.IsEssential = true;
});
// Below app.UseAuthorization();
app.UseSession();
See documentation for more details.
Python
See Flask-Caching
TypeScript
const cache = new Map<string, string>();
Cache needs to be defined somewhere so it persists across requests.
Authorization response / callback
After the user authenticates we get an authorization response back on the Callback endpoint.
In the callback you should:
- Exchange authorization code for access, refresh and ID tokens.
- Verify/validate ID token
- Fetch user info resource using the access token.
You can decode and extract the needed values from query parameters as following.
C-Sharp (C#)
public record AuthorizationResponse(string state, string code);
public async Task<IActionResult> Callback(AuthorizationResponse query)
{
var (state, code) = query;
// ...
return View();
}
Python
@app.route('/callback')
def callback():
state = request.args.get('state')
code = request.args.get('code')
# ...
return ""
TypeScript
type AuthorizationResponse = { state: string; code: string };
app.get("/callback", (req, res) => {
const { state, code } = req.query as AuthorizationResponse;
// ...
res.send();
});
Exchange authorization code for tokens
You need to exchange the authorization code for tokens.
C-Sharp (C#)
var parameters = new Dictionary<string, string?>
{
{ "grant_type", "authorization_code" },
{ "code", code },
{ "redirect_uri", redirectUri },
{ "code_verifier", codeVerifier },
{ "client_id", clientId },
{ "client_secret", clientSecret }
};
var response =
await new HttpClient().PostAsync(config.token_endpoint, new FormUrlEncodedContent(parameters));
var payload = await response.Content.ReadFromJsonAsync<TokenResponse>();
TokenResponse can be defined as
public class TokenResponse
{
public string access_token { init; get; }
public int expires_in { init; get; }
public string id_token { init; get; }
public string scope { init; get; }
public string token_type { init; get; }
public string refresh_token { init; get; }
}
Python
parameters = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"code_verifier": code_verifier,
"client_id": client_id,
"client_secret": client_secret
}
qs = urllib.parse.urlencode(parameters)
return requests.post(f"{token_endpoint}?{qs}", data=parameters).json()
TypeScript
const parameters = {
grant_type: "authorization_code",
code: code,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
client_id: clientId,
client_secret: clientSecret,
};
const response = await fetch(config.token_endpoint, {
method: "POST",
body: new URLSearchParams(parameters),
});
const payload = await response.json();
return payload as TokenResponse;
And TokenResponse defined as
type TokenResponse = {
access_token: string;
expires_in: number;
id_token: string;
scope: string;
token_type: string;
refresh_token: string;
};
Fetch user info
Now that you have an access token you can use it to fetch user info
C-Sharp (C#)
var http = new HttpClient
{
DefaultRequestHeaders =
{
{ "Authorization", "Bearer " + accessToken }
}
};
var response = await http.GetAsync(config.userinfo_endpoint);
var content = await response.Content.ReadFromJsonAsync<object?>();
Python
headers = {"Authorization": f"Bearer {access_token}"}
content = requests.get(userinfo_endpoint, headers=headers).json()
TypeScript
const response = await fetch(config.userinfo_endpoint, {
headers: {
Authorization: "Bearer " + accessToken,
},
});
const content = await response.json();
Securing your solution
There is a couple of things you need to do to secure your implementation.
Hash the code verifier
For Authorization Request you should replace code_challenge
with a base64 url
encoded sha256 hash of code_verifier
.
Also change code_challenge_method
to be S256
.
Verify ID Token
It is very important that you verify authenticity and integrity of the ID token.
For that we first need to fetch the public part of the key that was used to sign the token.
C-Sharp (C#)
var response = await new HttpClient().GetAsync(config.jwks_uri);
var keys = await response.Content.ReadAsStringAsync();
var jwks = JsonWebKeySet.Create(keys);
jwks.SkipUnresolvedJsonWebKeys = false;
Then use JwtSecurityTokenHandler
to validate/verify the token.
Python
You can use PyJWT to verify ID Token.
TypeScript
import jwksClient from "jwks-rsa";
var client = jwksClient({
jwksUri: config.jwks_uri,
});
function getKey(header: JwtHeader, callback: any) {
client.getSigningKey(header.kid, function (err, key: any) {
var signingKey = key.publicKey || key.rsaPublicKey;
callback(null, signingKey);
});
}
Then use verify
function from jsonwebtoken
package to validate/verify the token.
Session
Last step after establishing a valid user identity is to set up a session in your web application. So that verified user identity can persist across HTTP requests.
By following this guide you will be using server-side rendering of HTML (no JavaScript frontend). Therefore, the best way to maintain a session is with a cookie.
What are session & cookies?
C-Sharp (C#)
Learn about Session and state management in ASP.NET Core.
Python
See Flask Sessions
TypeScript
For express, you can use the session middleware.