This article to share how we use Keycloak OIDC authentication with N8N workflow used internally. Please Open It has its own instance of n8n for internal automations : billing, emails etc… Connecting applications to each other is simpler, especially on data management (json manipulations).
@JulienDelRio asks the n8n community for « social login » in n8n.
https://community.n8n.io/t/thinking-social-login-in-from-node/31789/3
Let’s answer this question.
Keycloak for the example
For this example, we use Keycloak. No specific feature from Keycloak is used, only keycloak.js library for the login form. Of course, any kind of oidc library will work in the same way. For us, it is easier to understand with this implementation for this use case.
A standard Keycloak (out of the box) from https://realms.please-open.it is used. Ensure that your Keycloak instance is accessible from your n8n instance.
Auth code with PKCE, anything else ?
Also for this example, we use authorization code with PKCE https://datatracker.ietf.org/doc/rfc7636/ for much simplicity in the workflow. After the user logs in, the token is directly retrieved by the web application, there is no operation between authentication server and n8n server to get the token.
Global view
The workflow looks like this :
The entrypoint is the webhook, after that we need to parse cookies HTTP header and ask for authentication or not.
After the authentication form, a cookie is created with the access token and the page is reloaded, so the process restarts. With the given token, user are retrieved with the /userinfo endpoint and voilà.
Configure the workflow
Set all variables first in the node « set variables » with :
- authorization_endpoint
- token_endpoint
- userinfo_endpoint
- client_id
- scope (with at least « openid » scope)
You can get those values directly in the « openid endpoint configuration » from your identity provider. In Keycloak, you have it in your « realm settings ».
Keycloak client configuration
A public client with standard flow enabled.
Code for cookies parsing
Cookies come « as is » from the webhook :
So parsing cookies header need a little Javascript code (split on ‘;’ and ‘=’):
let myCookies = {};
let cookies = [];
cookies = $input.item.json.headers.cookie.split(';')
for (item of cookies ) {
myCookies[item.split('=')[0].trim()]=item.split('=')[1].trim();
}
return myCookies;
The result is a structure of cookies, so it is possible to check if « n8n-custom-auth » is set.
First : the user is not logged in
The cookie does not exist, send a login page.
Login page
Thanks to https://github.com/curityio/pkce-javascript-example/tree/master with the implementation of PKCE on authorization_code, there is no external dependencies. It also helps to understand how PKCE works.
After authentication, the cookie « n8n-custom-auth » is set. Then the page is reloaded.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Login</title>
</head>
<body>
<div id="result"></div>
<script>
const authorizeEndpoint = "{{ $('Set variabes : auth, token, userinfo, client id, scope').item.json.auth_endpoint }}";
const tokenEndpoint = "{{ $('Set variabes : auth, token, userinfo, client id, scope').item.json.token_endpoint }}";
const clientId = "{{ $('Set variabes : auth, token, userinfo, client id, scope').item.json.client_id }}";
const scope = "{{ $('Set variabes : auth, token, userinfo, client id, scope').item.json.scope }}";
if (window.location.search) {
var args = new URLSearchParams(window.location.search);
var code = args.get("code");
if (code) {
var xhr = new XMLHttpRequest();
xhr.onload = function() {
var response = xhr.response;
var message;
if (xhr.status == 200) {
message = "Access Token: " + response.access_token;
document.cookie = "n8n-custom-auth="+response.access_token;
location.reload();
}
else {
message = "Error: " + response.error_description + " (" + response.error + ")";
}
document.getElementById("result").innerHTML = message;
};
xhr.responseType = 'json';
xhr.open("POST", tokenEndpoint, true);
xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
xhr.send(new URLSearchParams({
client_id: clientId,
code_verifier: window.sessionStorage.getItem("code_verifier"),
grant_type: "authorization_code",
redirect_uri: location.href.replace(location.search, ''),
code: code
}));
}
}
async function generateCodeChallenge(codeVerifier) {
var digest = await crypto.subtle.digest("SHA-256",
new TextEncoder().encode(codeVerifier));
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
}
function generateRandomString(length) {
var text = "";
var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
for (var i = 0; i < length; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
}
if (!crypto.subtle) {
document.writeln('<p>' +
'<b>WARNING:</b> The script will fall back to using plain code challenge as crypto is not available.</p>' +
'<p>Javascript crypto services require that this site is served in a <a href="https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts">secure context</a>; ' +
'either from <b>(*.)localhost</b> or via <b>https</b>. </p>' +
'<p> You can add an entry to /etc/hosts like "127.0.0.1 public-test-client.localhost" and reload the site from there, enable SSL using something like <a href="https://letsencrypt.org/">letsencypt</a>, or refer to this <a href="https://stackoverflow.com/questions/46468104/how-to-use-subtlecrypto-in-chrome-window-crypto-subtle-is-undefined">stackoverflow article</a> for more alternatives.</p>' +
'<p>If Javascript crypto is available this message will disappear.</p>')
}
var codeVerifier = generateRandomString(64);
const challengeMethod = crypto.subtle ? "S256" : "plain"
Promise.resolve()
.then(() => {
if (challengeMethod === 'S256') {
return generateCodeChallenge(codeVerifier)
} else {
return codeVerifier
}
})
.then(function(codeChallenge) {
window.sessionStorage.setItem("code_verifier", codeVerifier);
var redirectUri = window.location.href.split('?')[0];
var args = new URLSearchParams({
response_type: "code",
client_id: clientId,
code_challenge_method: challengeMethod,
code_challenge: codeChallenge,
redirect_uri: redirectUri,
scope: scope
});
window.location = authorizeEndpoint + "?" + args;
});
</script>
</body>
</html>
Second step : reload and get user info
A call to userinfo https://openid.net/specs/openid-connect-core-1_0.html#UserInfo instead of parsing a jwt token ?
Well… n8n does not have native JWT parsing functions. A library exists https://github.com/Joffcom/n8n-nodes-jwt with limitations, and of course if a token is revoked there is no way to detect this revocation.
Userinfo is the best way.
A GET request with access_token in Authorization header :
[
{
"sub": "17cd81c0-3169-4e87-bd44-7799185d472c",
"email_verified": false,
"name": "user user",
"preferred_username": "user",
"given_name": "user",
"family_name": "user",
"email": "user@test.com"
}
]
token expired ? Back to the login page !
Note that the Keycloak session (with its own cookie) bypasses the login form, so the user does not have to re-enter his credentials.
Next ?
With an access_token APIs calls can be done for the current logged in user, this is done in the « userinfo » request.
With user information, logs, emails, user details can be used in your flow.
[
{
"sub": "73a6543f-f420-4fa6-9811-209e903c348b",
"email_verified": true,
"preferred_username": "mathieu.passenaud@please-open.it",
"email": "mathieu.passenaud@please-open.it"
}
]
Source
Thanks to n8n creators, you can use our workflow directly from the official website : https://n8n.io/workflows/1997-authenticate-a-user-in-a-workflow-with-openid-connect/
Get this flow and feel free to use it !
The client used for the demo will work on all n8n webhooks due to a ‘*’ on redirect_uri. The user for testing is « test/test ».
- Keycloak roles restriction and full scopes - 10 décembre 2024
- How to enrich native metrics in KeyCloak - 21 août 2024
- Keycloak Authenticator explained - 7 mars 2024