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.

Keycloak OIDC authentication with N8N workflow

https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow-with-proof-key-for-code-exchange-pkce

Global view

The workflow looks like this :

Keycloak OIDC authentication with N8N workflow

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)
Keycloak OIDC authentication with N8N workflow

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.

Keycloak OIDC authentication with N8N workflow

Code for cookies parsing

Cookies come « as is » from the webhook :

Keycloak OIDC authentication with N8N workflow

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

Keycloak OIDC authentication with N8N workflow

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

Keycloak OIDC authentication with N8N workflow

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 :

Keycloak OIDC authentication with N8N workflow
[
  {
    "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.

Keycloak OIDC authentication with N8N workflow

Next ?

With an access_token APIs calls can be done for the current logged in user, this is done in the « userinfo » request.

Keycloak OIDC authentication with N8N workflow

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 !

auth-workflow.json

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 ».

Mathieu PASSENAUD
Les derniers articles par Mathieu PASSENAUD (tout voir)