I built the Kinde Remix SDK in a week

By Peter Phanouvong — Published

I’m Peter, one of Kinde’s software engineers.

After months of requests, I set out a couple of weeks ago to build Kinde’s Remix SDK.

The process took a week, and I thought it valuable for our community, and others building their own SDKs, to see the process I went through. Hopefully, it helps those looking to do the same.

Step 1 - Explore the problem & potential solutions

Link to this section

The problem was not having a Kinde Remix SDK.

Simple solution; build one.

Find inspiration

Link to this section

I used the Kinde Next.js SDK as inspiration for the Remix MVP.

  • Endpoints to handle authentication - sign up, sign in and logout
  • A helper function to get Kinde data from the session getKindeServerSession

The 80/20 rule, also known as the Pareto principle, is a statistical rule that states that 80% of outcomes result from 20% of causes

I cut out things that weren’t necessary for the MVP

  • No middleware
  • No client-side authentication
  • No custom sessionManager

Now I know what I’m building.

Step 2 - Build the SDK

Link to this section

For the MVP, I favored speed over functionality. It made it easier for me to develop and get quick feedback to guide decisions down the track.

  • JavaScript (w/ JSDoc) over TypeScript to avoid annoying type errors
  • I utilized the Kinde TS SDK so I didn’t have to rebuild functions that handle authentication

Building a (crude) proof of concept

Link to this section

The first version of the SDK can be found on GitHub.

Set things up

  • I created a new package using npm init and applied my desired settings
  • I created a new Remix app using npx create-remix@latest
  • I made the package available to the Remix app with npm link @kinde-oss/kinde-remix-sdk

Use the TS SDK

  • I installed the TypeScript SDK npm i @kinde-oss/kinde-typescript-sdk

  • I created a SessionManager following the interface provided by the TS SDK

    // handle-auth.js
    import {createCookieSessionStorage} from "@remix-run/node";
    
    export const sessionStorage = createCookieSessionStorage({
        cookie: {
            name: "kinde_session",
            httpOnly: true,
            path: "/",
            sameSite: "lax",
            secrets: [process.env.SESSION_SECRET],
            secure: process.env.NODE_ENV === "production"
        }
    });
    
    const cookie = request.headers.get("Cookie");
    const session = await sessionStorage.getSession(cookie);
    
    const sessionManager = {
        async getSessionItem(key) {
            return session.get(key);
        },
        async setSessionItem(key, value) {
            return session.set(key, value);
        },
        async removeSessionItem(key) {
            return session.unset(key);
        },
        async destroySession() {
            return sessionStorage.destroySession(session);
        }
    };
    
    • The sessionManager in this case makes use of createCookiesSessionStorage from @remix-run/node which is a utility function to help handle session storage using a cookie.
  • Defined config to grab environment variables

    // config.js
    
    export const config = {
        clientId: process.env.KINDE_CLIENT_ID,
        clientSecret: process.env.KINDE_CLIENT_SECRET,
        issuerUrl: process.env.KINDE_ISSUER_URL,
        siteUrl: process.env.KINDE_SITE_URL,
        postLogoutRedirectUrl: process.env.KINDE_POST_LOGOUT_REDIRECT_URL,
        postLoginRedirectUrl: process.env.KINDE_POST_LOGIN_REDIRECT_URL,
        audience: process.env.KINDE_AUDIENCE,
        cookieMaxAge: process.env.KINDE_COOKIE_MAX_AGE
    };
    
  • Created a kindeClient using the TS SDK and config

    const kindeClient = createKindeServerClient(GrantType.AUTHORIZATION_CODE, {
        authDomain: config.issuerUrl,
        clientId: config.clientId,
        clientSecret: config.clientSecret,
        redirectURL: config.siteUrl + "/kinde-auth/callback",
        logoutRedirectURL: config.postLogoutRedirectUrl
    });
    

Endpoints to handle authentication

  • Created the handleAuth function to handle auth endpoints

    • I defined login, register, callback and logout function.
    • The auth functions use the kindeClient to do most of the heavy lifting.
    • Remix specific code is added to the end of the functions to ensure the session is being saved correctly.
    • There is a switcher at the end to call the correct function based on the endpoint that is being redirected to. i.e. if you hit /kinde-auth/login the login function is called.
    // handle-auth.js
    
    ...
    export const handleAuth = async (request, route) => {
      const cookie = request.headers.get("Cookie");
      const session = await sessionStorage.getSession(cookie);
    
      const login = async () => {
        const authUrl = await kindeClient.login(sessionManager);
        const { searchParams } = new URL(request.url);
        const postLoginRedirecturl = searchParams.get("returnTo");
    
        if (postLoginRedirecturl) {
          session.set("post_login_redirect_url", postLoginRedirecturl);
        }
    
        return redirect(authUrl.toString(), {
          headers: {
            "Set-Cookie": await sessionStorage.commitSession(session, {
              maxAge: config.cookieMaxAge || undefined,
            }),
          },
        });
      };
    
      const register = async () => {
        const authUrl = await kindeClient.register(sessionManager);
        const { searchParams } = new URL(request.url);
        const postLoginRedirecturl = searchParams.get("returnTo");
    
        if (postLoginRedirecturl) {
          session.set("post_login_redirect_url", postLoginRedirecturl);
        }
    
        return redirect(authUrl.toString(), {
          headers: {
            "Set-Cookie": await sessionStorage.commitSession(session, {
              maxAge: config.cookieMaxAge || undefined,
            }),
          },
        });
      };
    
      const callback = async () => {
        await kindeClient.handleRedirectToApp(sessionManager, new URL(request.url));
    
        const postLoginRedirectURLFromMemory = await sessionManager.getSessionItem(
          "post_login_redirect_url"
        );
    
        if (postLoginRedirectURLFromMemory) {
          sessionManager.removeSessionItem("post_login_redirect_url");
        }
    
        const postLoginRedirectURL = postLoginRedirectURLFromMemory
          ? postLoginRedirectURLFromMemory
          : config.postLoginRedirectUrl;
    
        return redirect(postLoginRedirectURL, {
          headers: {
            "Set-Cookie": await sessionStorage.commitSession(session, {
              maxAge: config.cookieMaxAge || undefined,
            }),
          },
        });
      };
    
      const logout = async () => {
        const authUrl = await kindeClient.logout(sessionManager);
    
        return redirect(authUrl.toString(), {
          headers: {
            "Set-Cookie": await sessionStorage.destroySession(session),
          },
        });
      };
    
      switch (route) {
        case "login":
          return login();
        case "register":
          return register();
        case "callback":
          return callback();
        case "logout":
          return logout();
      }
    };
    

Kinde session data

  • Created a function getKindeSession to that utilizes the kindeClient helper functions to help users access session data.

    • This function is basically a wrapper for the kindeClient helper functions.
    import {kindeClient} from "./handle-auth";
    import {createSessionManager} from "./session/session";
    import {generateCookieHeader} from "./utils/cookies";
    
    export const getKindeSession = async (request) => {
        const {sessionManager, cookies} = await createSessionManager(request);
    
        const getClaimValue = (claim, type) =>
            kindeClient.getClaimValue(sessionManager, claim, type);
        const getClaim = (claim, type) => kindeClient.getClaim(sessionManager, claim, type);
        const getToken = () => kindeClient.getToken(sessionManager);
        const refreshTokens = async () => {
            await kindeClient.refreshTokens(sessionManager);
            const headers = generateCookieHeader(request, cookies);
            return headers;
        };
        const isAuthenticated = () => kindeClient.isAuthenticated(sessionManager);
        const getUser = () => kindeClient.getUser(sessionManager);
        const getUserProfile = () => kindeClient.getUserProfile(sessionManager);
        const getFlag = (code, defaultValue, type) =>
            kindeClient.getFlag(sessionManager, code.toLowerCase(), defaultValue, type);
        const getBooleanFlag = (code, defaultValue) =>
            kindeClient.getBooleanFlag(sessionManager, code.toLowerCase(), defaultValue);
        const getIntegerFlag = (code, defaultValue) =>
            kindeClient.getIntegerFlag(sessionManager, code.toLowerCase(), defaultValue);
        const getStringFlag = (code, defaultValue) =>
            kindeClient.getStringFlag(sessionManager, code.toLowerCase(), defaultValue);
        const getPermission = (permission) => kindeClient.getPermission(sessionManager, permission);
        const getPermissions = () => kindeClient.getPermissions(sessionManager);
        const getOrganization = () => kindeClient.getOrganization(sessionManager);
        const getUserOrganizations = () => kindeClient.getUserOrganizations(sessionManager);
    
        return {
            getClaim,
            getClaimValue,
            getOrganization,
            getPermission,
            getPermissions,
            getFlag,
            getStringFlag,
            getBooleanFlag,
            getIntegerFlag,
            getToken,
            getUser,
            getUserProfile,
            getUserOrganizations,
            isAuthenticated,
            refreshTokens
        };
    };
    

Building with Rollup

  • Installed dev dependencies rollup and @rollup/plugin-terser

  • Created a rollup.config.mjs file

    // rollup.config.mjs
    
    import terser from "@rollup/plugin-terser";
    export default {
        input: "src/index.js",
        output: [
            {
                file: "dist/index.js",
                format: "esm",
                exports: "named"
            },
            {
                file: "dist/cjs/index.js",
                format: "cjs",
                exports: "named"
            }
        ],
        plugins: [terser()],
        external: ["@kinde-oss/kinde-typescript-sdk", "@remix-run/node", "universal-cookie"]
    };
    
  • Added a build script to package.json

    "build": "npx tsc && cp src/types.d.ts dist/types/types.d.ts && genversion --es6 src/utils/version.js && npx rollup --c",
    
    • The most important part is npx rollup --c which builds it and sends it to the dist folder.
    • The other stuff checks types and handles version control
  • Confirmed package details in package.json

      "main": "dist/cjs/index.js",
      "module": "dist/index.js",
      "typings": "dist/types/index.d.ts",
      "type": "module",
    
    • I made sure that the main file points to the CommonJS export, module was ESM, typings to the type definition file and to say that the package was of type module.
  • Now when I run npm run build , it builds the project and I can test the changes in the linked Remix project

  • Here is the final package.json

    {
      "name": "@kinde-oss/kinde-remix-sdk",
      "version": "1.0.1",
      "description": "",
      "main": "dist/cjs/index.js",
      "module": "dist/index.js",
      "typings": "dist/types/index.d.ts",
      "type": "module",
      "exports": {
        ".": {
          "import": {
            "types": "./dist/types/index.d.ts",
            "default": "./dist/index.js"
          },
          "require": {
            "types": "./dist/types/index.d.ts",
            "default": "./dist/cjs/index.js"
          }
        },
        "./types": {
          "import": {
            "types": "./dist/types/types.d.ts",
            "default": "./dist/index.js"
          }
        }
      },
      "scripts": {
        "build": "npx tsc && cp src/types.d.ts dist/types/types.d.ts && genversion --es6 src/utils/version.js && npx rollup --c",
        "release": "release-it",
        "test": "vitest",
        "test:coverage": "vitest --coverage",
        "dev:prepare": "rm -rf playground && git clone https://github.com/kinde-starter-kits/kinde-auth-remix-starter-kit.git playground && npm link && cd playground && mv .env.sample .env && npm uninstall @kinde-oss/kinde-remix-sdk && rm -rf .git && npm link @kinde-oss/kinde-remix-sdk",
        "dev": "cd playground && npx remix dev --manual",
        "lint": "eslint . --ext .ts --fix && prettier --write ."
      },
      "author": {
        "name": "Kinde",
        "email": "engineering@kinde.com",
        "url": "https://kinde.com"
      },
      "license": "ISC",
      "repository": {
        "type": "git",
        "url": "https://github.com/kinde-oss/kinde-remix-sdk"
      },
      "bugs": "https://github.com/kinde-oss/kinde-remix-sdk",
      "homepage": "https://kinde.com",
      "dependencies": {
        "@kinde-oss/kinde-typescript-sdk": "^2.8.0",
        "jwt-decode": "^4.0.0",
        "prettier": "^3.2.5",
        "universal-cookie": "^7.1.4"
      },
      "devDependencies": {
        "@rollup/plugin-terser": "^0.4.4",
        "@types/node": "^20.10.7",
        "@typescript-eslint/eslint-plugin": "^6.21.0",
        "@vitest/coverage-v8": "^1.4.0",
        "eslint": "^8.57.0",
        "eslint-config-standard-with-typescript": "^43.0.1",
        "eslint-plugin-import": "^2.29.1",
        "eslint-plugin-n": "^16.6.2",
        "eslint-plugin-promise": "^6.1.1",
        "genversion": "^3.2.0",
        "release-it": "^17.1.1",
        "rollup": "^4.9.4",
        "typescript": "^5.4.3",
        "vitest": "^1.4.0"
      },
      "peerDependencies": {
        "@remix-run/node": "^2.4.1"
      }
    }
    

Step 3 - Test it

Link to this section

We needed to ensure it worked. A lot of testing and tinkering got us to a functional package.

I created a starter kit that used the Remix SDK to handle authentication and had logic to modify the user experience in-app based on their authentication status. This starter kit could then serve as a template for anyone who wants to get started quickly with our Remix SDK.

  • Imported and set up the package

    // app/routes/kinde-auth.$index.tsx
    
    import {handleAuth} from "@kinde-oss/kinde-remix-sdk";
    import {LoaderFunctionArgs} from "@remix-run/node";
    
    export async function loader({params, request}: LoaderFunctionArgs) {
        return await handleAuth(request, params.index);
    }
    
    // .env
    
    KINDE_CLIENT_ID=463db732edf24274xxxxx6d3e2
    KINDE_CLIENT_SECRET=hicl6tVHttRzo2HJ2EMkBuhVUtuv2dLbVJy5ikxxx6dmcYaiWqi
    KINDE_ISSUER_URL=https://xxx.kinde.com
    KINDE_SITE_URL=http://localhost:3000
    KINDE_POST_LOGOUT_REDIRECT_URL=http://localhost:3000
    KINDE_POST_LOGIN_REDIRECT_URL=http://localhost:3000
    SESSION_SECRET=secret
    
  • First, I made sure endpoints handled redirects to Kinde and back

    <Link to="/kinde-auth/login" className="btn btn-dark">
        Sign up
    </Link>
    
    • I made sure this link would take me to Kinde and then when redirected back (after the auth flow), the callback could handle response.
  • I checked that the session was being set correctly in the cookies

    • I made sure it contained the access_token, id_token, refresh_token and user
    • I also made sure after hitting the /kinde-auth/logout endpoint, the session cookies were cleared.
  • I checked the session helpers (getKindeSession)

    export const loader = async ({ request }: LoaderFunctionArgs) => {
      const { getUser } = await getKindeSession(request);
      const user = await getUser();
      return json({ user });
    };
    
    • refreshTokens was not working as intended. The error was saying there was not enough space in the cookie to handle all the data.
  • At this point I had

    • Working Links that redirected to Kinde and back
    • getKindeSession working to get the authentication status
  • Then I made it pretty with CSS, using the Next.js SDK as inspiration

For contributors, a playground is convenient because it means they don’t have to create their own Remix app to test their changes. They can just pull in the playground with a script and see the effects of their changes in the package with some npm link magic.

  • I published the starter kit to Github so it could then be pulled in by our package as a playground
  • I wrote the scripts dev and dev:prepare
    "dev:prepare": "rm -rf playground && git clone https://github.com/kinde-starter-kits/kinde-auth-remix-starter-kit.git playground && npm link && cd playground && mv .env.sample .env && npm uninstall @kinde-oss/kinde-remix-sdk && rm -rf .git && npm link @kinde-oss/kinde-remix-sdk",
    "dev": "cd playground && npx remix dev --manual",
    
    • **dev:prepare** removes the current playground folder, pulls in the starter kit from Github, renames .env.sample to .env and links the package to the playground
    • **dev** changes directory into the playground and then runs the remix app
  • So now running the dev:prepare script created or updated the playground folder which had the starter kit that could be run locally with npm run dev

Step 4 - Polish it

Link to this section

After confirming the functionality by testing changes with the playground, it was time to get the package ready for release.

During the testing process, lots of console.logs were used and bits of code were added to get things to work. We don’t want unnecessary code to make it into the published version for developers, so I removed all the needles code.

To enhance the developer experience, I added in the JSDoc definitions to all the functions. Since I had set up the project to have JSDoc work with TypeScript, it meant that there was good linting and that developers could get the type definitions come through when using the package.

In addition to JSDoc, I also included a /types export which made common types from the TS SDK available to devs.

Error handling

Link to this section

This is still something we are trying to improve. At the moment if a function threw an error the package would just default to also throwing an error. I put in try-catch blocks to catch errors and then handle appropriately; throwing an error in some cases, sometimes just an error log or providing a more useful error message.

I set up Vitest for testing. With it I created unit tests to get ~98% code coverage. Unit tests are nice because they can give an indication if your changes will cause breakages.

I installed release-it to handle releases. With it, you can define the type of release (pre-release, patch, major etc) and it will update the package.json , commit changes to the GitHub and publish to npm.

Step 5 - Keep going

Link to this section

When building the MVP, I deliberately left out some difficult tasks that would be a time sink. In particular, I disregarded the error I had gotten with refreshTokens which said there wasn’t enough space in the cookie to hold all the data.

Now, happy with MVP, I revisited this issue and re-ran the loop on this problem because I thought it was important to tackle.

Meaningful changes

Link to this section

It would be a meaningful change to get refreshTokens to work. So I applied to loop

  1. Problem
    1. refreshTokens was no good- one cookie couldn’t hold all the data
  2. Build something
    1. Inspiration - the Next.js SDK breaks it into separate cookies, so that’s what I did
    2. This required a rewrite of the sessionManager and handleAuth
  3. Test it
    1. I used the playground to continually test - needed to bring in separate library to handle cookie parsing and setting
    2. I realized that to get it to work I needed to process the headers before saving the cookies
  4. Polish
    1. Removed needless code and console logs
    2. Updated the tests
    3. Released it with release-it
    4. Updated the starter kit to use new version

How I did it all in one (action-packed) week

Link to this section

I wrote this SDK the week I got back from Japan, and adopted a new puppy.

Here are some things that helped me out.

Writing checklists

Link to this section

Instead of trying to keep everything in my head, I used Todoist to track all of my todos.

I also had a checklist for before starting my workday (plan out the day, preparing my workspace) and a checklist for ending my workday (cleaning up my computer & workspace, writing down loose ends).

Prioritising deep work

Link to this section

In the morning I would schedule in deep work sessions (1-2hrs long) on my Notion Calendar. I would go to Todoist and move some todos into the deep work block.

Before these blocks I would make sure to tire out Alfie (my new puppy) then to lock-in; noise cancelling headphones on, phone away, slack notifications off and just work. It’s crazy how much you can do in an hour or two.

Staying accountable

Link to this section

At the end of the day I would send an update to the Slack channel so people knew what I was up to. It felt good to show off the work that was getting done and the implied accountability was good for motivation.

Doing this at the end of the day was also a nice way to reflect and tie up or capture loose ends.

This loop really worked for me. It was important for me to look out for loops inside loops inside loops that would become a time sink and distract away from the main objective.

If I realized the inner loop was a massive time sink, I tried to leave it and get back to it afterward.

  1. Explore the problem + potential solutions
  2. Build something
  3. Test it
  4. Polish it
  5. Keep going

That wraps up an exciting SDK build. Reach out to me via peter@kinde.com if you have any questions. Glad to be sharing my process.