JSPM

  • Created
  • Published
  • Downloads 40
  • Score
    100M100P100Q75318F
  • License MIT

Strapi plugin to support Keycloak authentication of end-users using a middleware.

Package Exports

    This package does not declare an exports field, so the exports above have been automatically detected and optimized by JSPM instead. If any package subpath is missing, it is recommended to post an issue to the original package (@hipsquare/strapi-plugin-keycloak) to support the "exports" field. If that is not possible, create a JSPM override to customize the exports field for this package.

    Readme

    Strapi Keycloak Plugin

    This is a Strapi plugin to support Keycloak authentication for end-users. It is not designed for admin users.

    Quickstart

    To configure Keycloak, see this guide.

    Install the plugin in your Strapi project:

    yarn add @hipsquare/strapi-plugin-keycloak

    Enable the plugin in config/plugins.js (create the file if it does not exist so far):

    module.exports = {
      keycloak: {
        enabled: true,
      },
    };

    Create config/keycloak.js and configure Keycloak accordingly:

    module.exports = {
      // client ID configured in Keycloak
      clientId: "strapi",
    
      // if the client access type is set to "confidential" in keycloak, add the client secret here. otherwise, don't set this value.
      clientSecret: "abcdefg",
    
      // auth endpoint, right value comes from Keycloak
      authEndpoint:
        "http://localhost:8080/realms/strapi/protocol/openid-connect/auth",
    
      // token endpoint, right value comes from Keycloak
      tokenEndpoint:
        "http://localhost:8080/realms/strapi/protocol/openid-connect/token",
    
      // user info endpoint, right value comes from Keycloak
      userinfoEndpoint:
        "http://localhost:8080/realms/strapi/protocol/openid-connect/userinfo",
    
      // logout endpoint, right value comes from Keycloak
      logoutEndpoint:
        "http://localhost:8080/realms/strapi/protocol/openid-connect/logout",
    
      // redirect URI after Keycloak login, should be the full URL of the Strapi instance and always point to the `keycloak/callback` endpoint
      redirectUri: "http://localhost:1337/keycloak/callback",
    
      // default URL to redirect to when login process is finished. In normal cases, this would redirect you back to the application using Strapi data
      redirectToUrlAfterLogin: "http://localhost:1337/api/todos",
    
      // setting these allows the client to pass a `redirectTo` query parameter to the `login` endpoint. If the `redirectTo`
      // parameter is permitted by this array, after login, Strapi will redirect the user to it. Leave empty to disable
      // the functionality.
      permittedOverwriteRedirectUrls: [
        "http://localhost:1337",
        "http://localhost:1338",
      ],
    
      // URL to redirect to after logout
      redirectToUrlAfterLogout: "http://localhost:1337/",
    
      // enable debug messages in server log
      debug: true,
    };

    Protecting Strapi routes

    To protect a route, apply the middleware to that route in api/[content-type]/routes/[content-type].js (in our example todo).

    const { createCoreRouter } = require("@strapi/strapi").factories;
    
    module.exports = createCoreRouter("api::todo.todo", {
      config: {
        find: {
          middlewares: ["plugin::keycloak.keycloak"],
        },
      },
    });

    Restart Strapi.

    Open http://localhost:1337/keycloak/login to start the login process.

    Now open the find endpoint of your content type, in this example http://localhost:1337/api/todos.

    Using Strapi API Tokens

    Strapi introduced API Tokens in version 4, which are meant to allow bypassing other means of authorization when set. The middleware takes API tokens into account. If a valid API token is set, there will be no check for a valid Keycloak login.

    Login flow for frontend apps

    The login flow above works, but only in environments where session cookies are supported (so most browser use cases). It doesn't work that well, however, for Capacitor or other native applications that don't fully support session cookies.

    To solve that, you can set appendAccessTokenToRedirectUrlAfterLogin to true in the config. When redirecting to redirectToUrlAfterLogin, it will append a query parameter called accessToken with the access token retrieved.

    The login flow then would work like that:

    1. The frontend application redirects to Strapi's /keycloak/login endpoint. Optionally pass ?redirectTo=http://my-url to redirect to that URL after login. Only works if the URL is part of permittedOverwriteRedirectUrls.
    2. Strapi initiates the login with Keycloak.
    3. Once done, Strapi redirects back to the frontend using the defined redirectToUrlAfterLogin and appends the access token as a query parameter accessToken.
    4. The frontend reads the query parameter, stores it (e.g. session storage) and and sets the Keycloak header in requests to Strapi:
      curl http://localhost:1337/api/todos -H "Keycloak: Bearer [Access Token]"

    Check if user is logged in

    To check if the user is currently logged in with a valid access token, you can call the /keycloak/isLoggedIn endpoint. It will return true or false.

    The endpoint works both with session cookies and with an explicitly set access token in the Keycloak header.

    Get user profile

    Using the /keycloak/profile route, you can fetch the user's keycloak profile:

    $ curl http://localhost:1337/keycloak/profile
    
    {"sub":"deab236b-db26-4b25-afa9-ce5132503afe","email_verified":true,"name":"John Doe","preferred_username":"john.doe","given_name":"John","family_name":"Doe"}

    Get login status and user profile from access token and avoid Keycloak roundtrip

    By default, the plugin will check for the current login status by calling Keycloak's userinfo endpoint. For Strapi instances with many requests, this can become a performance bottleneck.

    You can change this behavior by providing Keycloak's public key, which allows the plugin to verify and decode the access token provided by Keycloak. Like that, the plugin will not contact Keycloak anymore to verify the user's login status, but rely on the verification status of the access token.

    To enable that behavior, define the jwtPublicKey configuration property in config/keycloak.js:

    module.exports = {
      jwtPublicKey: "Iadoghdsgh...",
    };

    You can find the public key in Keycloak under "Realm Settings" in the "Keys" tab. Look for the "RSA"-type public key with a "SIG" use, and click the "Public key" button to retrieve the public key.

    If you use a non-default algorithm to sign access tokens, you can define it with jwtAlgorithm:

    module.exports = {
      jwtAlgorithm: "RS256",
    };

    If you don't set jwtAlgorithm, it defaults to RS256.

    Access the user profile in Strapi code

    When a user is logged in, the middleware will populate ctx.state.keycloak.profile with the current user's profile:

    console.log("The current user is ", ctx.state?.keycloak?.profile);

    Logout

    To initiate a logout, redirect the user to /keycloak/logout.

    You can append a redirectTo query parameter to forward the user to a custom URL:

    http://localhost:1337/keycloak/logout?redirectTo=http://myfrontend/login

    If none is specified, the user will be redirected to redirectToUrlAfterLogout defined in the configuration.

    Lifecycle Hooks

    You can optionally provide lifecycle hooks via the configuration:

    onLoginSuccessful, onLoginFailed

    module.exports = {
      onLoginSuccessful: (ctx) => console.log("Login was successful"),
      onLoginFailed: (ctx) => console.log("Login failed"),
    };

    These functions receive the full Koa context and can interact with it.

    onRetrieveProfile

    Additionally, you can use onRetrieveProfile to enrich the user profile returned by the /profile endpoint with custom information:

    module.exports = {
      onRetrieveProfile: (ctx) => ({ customGreeting: "hello" }),
    };

    The returned object will be merged with the user profile retrieved from the IdP.

    afterRetrieveProfile

    You can use afterRetrieveProfile which will be called after the profile has been retrieved from the IdP and after onRetrieveProfile has been called. It gets both the context and the fetched user profile handed over as arguments.

    module.exports = {
      afterRetrieveProfile: (ctx, userProfile) => {
        userProfile.randomField = "randomValue";
      },
    };

    Q&A

    Does the plugin work with other identity providers than Keycloak?

    The plugin implements the default OpenID Connect Authorization Code Flow. That's why it works with other Identity Providers than Keycloak, too. We have tested it with Auth0 and Azure Active Directory the login process works seamlessly.

    The package name is somewhat misleading therefore -- we might change it to reflect the broader IdP support in the future.

    I can login successfully, but isLoggedIn returns false/no session is created

    One common reason for this is that the access and ID tokens supplied by your identity provider are very long. Strapi uses koa-session for session management, and koa-session stores all session information in a client-side browser cookie. Browser cookies have length limits, and if your tokens exceed that length limit, Strapi will fail to create a session.

    As a solution, we recommend to use an external session store. See the koa-session documentation for details.

    A primitive implementation in the Strapi session middleware config that we do not recommend for production could looks like this:

    const sessionStore = new Map<string, { session: unknown; expires: number }>();
    
    export default [
      {
        name: "strapi::session",
        config: {
          store: {
            async get(key: string) {
              const sessionInfo = sessionStore.get(key);
    
              if (!sessionInfo) {
                return;
              }
    
              if (sessionInfo.expires < +new Date()) {
                return;
              }
    
              return sessionInfo.session;
            },
            async set(key: string, session: unknown, maxAge: number) {
              sessionStore.set(key, {
                session,
                expires: +new Date() + maxAge * 1000,
              });
            },
            async destroy(key) {
              sessionStore.delete(key);
            },
          },
        },
      },
    ];