DEV Community

Riccardo Perra
Riccardo Perra

Posted on

SpecFlow - Integrating Hanko and Supabase in a Solid.js client-side application

Lately at work I've had the need to write technical documentation and have to generate different types of diagrams and share them.

I've always used the mermaid live editor but found it rather inconvenient, so I took advantage of hanko's hackathon to build SpecFlow: a tool which allows you to centralize all your project specs and documentation.

What's Hanko?

From their GitHub:

Hanko is an open-source authentication and user management solution with a focus on moving the login beyond passwords while being 100% deployable today.

  • Built for passkeys as introduced by Apple, Google, and Microsoft
  • Fast integration with Hanko Elements web components (login box and user profile)
  • API-first, small footprint, cloud-native

It's a really interesting solution and is probably now the one that allows you to integrate passkeys in the easiest/fastest way possible.

About SpecFlow

SpecFlow is an open-source tool available on GitHub and hosted on Netlify which uses Hanko for authenticate their users. Its'currently an MVP.

Repository

GitHub logo riccardoperra / specflow

Write markdown documentation and generate mermaid diagrams with ease - Made with Hanko, SolidJS and Supabase.

Note
SpecFlow is an open-source tool (MIT License) made for the Hanko hackathon It's an MVP made in less than two weeks far away to be a complete product, born with the aim of testing integrations and interactions between new tech/libraries, and to better understand the authentication flow by also integrating passkeys.

More in detail, in this project I experiment with Hanko's authentication by integrating it with a third party system like supabase, the latter used trying to take advantage of the generated types, RLS policies, realtime and edge functions.

Furthermore, I made a small use of OpenAI API via edge functions to generate code directly from a user-defined prompt.

This project it's also a way to improve my UI Kit library based on Kobalte and Vanilla Extract that I'm working on, initially born to be the CodeImage design system.

Homepage of SpecFlow

πŸ’‘ Features

  • βœ… SpecFlow provides you with a…

App

It hasn't obviously created to replace products currently existing on the market, but it's a way for me to try new technologies and see how they perform together.

SpecFlow is mainly designed to to succeed in these tasks:

  • βœ… Secure your data so that it is only accessible if authenticated
  • βœ… Write project notes, requirement and specifications using a Markdown-like interface.
  • βœ… Write and export diagrams such as sequence diagrams, ER, Mind maps etc. complaint to mermaid syntax.
  • βœ… Make a small use of AI to see how to integrate OpenAI into an application

πŸ’» Screenshots

Login page

Login Page

Passcode challenge

Passcode challenge login

Dashboard

Dashboard

Project page - Markdown editor

Project page - Markdown editor

Project page - Diagrams editor

Project page - Diagrams editor

Project page - Exports a diagram

Project page - Exports a diagram

Profile page

Profile page

πŸ€– Tech stack

SpecFlow is a client-side SPA built using SolidJS as a front-end framework, Supabase for database, realtime, edge functions...and Hanko to handle the authentication.

All styles have been made with Vanilla-Extract and Tailwind integrating a design system I used for CodeImage which is a wrapper of Kobalte

To manage the editor I used CodeMirror6 and Tiptap, the latter used to preview the markdown having the chance one day to also integrate a wysiwyg editor.

For a better development experience, I also integrated MockServiceWorker in order to be able to mock the entire auth flow and develop by integrating Hanko components without disturbing the server.

πŸ” Integrating Hanko in a SolidJS application

Hanko employes did a really great job regarding the documentation: everything is extremely clear and ready to use.

https://docs.hanko.io/introduction

Currently there isn't a dedicated guide for SolidJS, then I've followed a bit the documentation of react/angular docs, but the steps for Solid are almost identical.

Integrating Hanko in a SPA

First of all, we need to install the @teamhanko/hanko-elements package, which will contains the Auth and Profile web components which we will use later to manage authentication.

pnpm add @teamhanko/hanko-elements
Enter fullscreen mode Exit fullscreen mode

Once installed, we should retrieve the Hanko API URL from their cloud console and place it in our .env file.

VITE_HANKO_API_URL=https://f4****-4802-49ad-8e0b-3d3****ab32.hanko.io
Enter fullscreen mode Exit fullscreen mode

Next, we can integrate the auth web component by importing the register function in order to register <hanko-auth> and <hanko-profile> with the browser CustomElementRegistry.

Note that unlike react, we need to overwrite the SolidJS namespace to add hanko's custom elements.

import { onMount } from "solid-js";
import { register } from "@teamhanko/hanko-elements";

const hankoApi = import.meta.env.VITE_HANKO_API_URL;

export function HankoAuth() {
  onMount(() => {
    register(hankoApi).catch((error) => {
      // handle error
    });
  });
  return <hanko-auth />;
}

type JsxIntrinsicElements = JSX.IntrinsicElements;

declare module "solid-js" {
  namespace JSX {
    interface IntrinsicElements {
      "hanko-auth": JsxIntrinsicElements["hanko-auth"];
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Once done, we are able to use our component in order to authenticate. The same steps could be done for the Profile page.

πŸ” How's Hanko can be leveraged with Supabase

We have seen that in a few lines of code you can integrate Hanko into your application, but now comes the challenging part: the integration with supabase.

Supabase Database comes with a useful RLS policy which
allows to restrict the data access using custom rules (postgres policies).

In a traditional supabase application we've could used it's authentication system and make use of the Auth client of their library.

In SpecFlow, Hanko is replacing supabase auth, so we need to somehow make supabase understand who is making the requests in order to apply all related policies. For example we need that each user can view or update only the entities he own.

To authenticate these requests, we can sign our own JWT that contains the necessary info for supabase in order to apply the policies.

In the steps following we will cover the creation of a JWT that contains hanko's user_id signing it with the supabase private key, and then integration with supabase database and the UI.

1. Creating a postgres function for supabase to retrieve a user_id from a given jwt.

In The first step we'll create a postgres function which will extract the hanko user_id from a JWT in order to let supabase knows which user is authenticated.

create function auth.hanko_user_id() returns text as $$
select nullif(current_setting('request.jwt.claims', true)::json ->> 'userId', '')::text;
$$
language sql stable;
Enter fullscreen mode Exit fullscreen mode

2. Creating a postgres policy to restrict our data

We can now define a new policy in order to restrict our data. The following example uses a todos table as an example.

CREATE POLICY "Allows all operations" ON public.todos
AS PERMISSIVE FOR ALL
TO public
-- βœ… Use our user_id function to get hanko user_id from jwt
USING ((auth.hanko_user_id() = user_id))
WITH CHECK ((auth.hanko_user_id() = user_id));

ALTER TABLE public.todos ENABLE ROW LEVEL SECURITY;
Enter fullscreen mode Exit fullscreen mode

3. Writing the business logic to sign our custom JWT

The signing of the JWT must always be done server-side in order to keep our private keys safe.

Since we are using supabase we can take advantage of their edge-functions (https://supabase.com/docs/guides/functions) that runs on Deno in order to build the JWT.

Another solution would have been for example to have a custom backend or to use a full stack front-end framework like Solid Start

You can read more info about jwt here.

We've to carry out 3 steps:

  • Retrieves Hanko jwks configurations. JWKS is a set of keys containing the public keys used to verify any JSON Web Token
  • Thanks to the JWKS we can verify the Hanko JWT sent from the UI request in order to be sure it's a valid token.
  • Signing a new JWT valid for supabase including in the payload the hanko user_id claim.
import * as jose from "https://deno.land/x/jose@v4.9.0/index.ts";

Deno.serve(async (req) => {
  const session = ((await req.json()) ?? {}) as { jwt: string };
  const hankoApiUrl = Deno.env.get("HANKO_API_URL");

  // 1. βœ… Retrieves Hanko JWKS configuration
  const JWKS = jose.createRemoteJWKSet(
    new URL(`${hankoApiUrl}/.well-known/jwks.json`),
  );

  // 2. βœ… Verify Hanko token
  const data = await jose.jwtVerify(session.jwt, JWKS);

  const payload = {
    exp: data.payload.exp,
    userId: data.payload.sub,
  };

  // 3. βœ… Sign new token for supabase using it's private key
  const supabaseToken = Deno.env.get("PRIVATE_KEY_SUPABASE");
  const secret = new TextEncoder().encode(supabaseToken);
  const token = await jose
    .SignJWT(payload)
    .setExpirationTime(data.payload.exp)
    .setProtectedHeader({alg: "HS256"})
    .sign(secret);
  return new Response(JSON.stringify({token}))
})
Enter fullscreen mode Exit fullscreen mode

Once the edge function is deployed, we intercept the event once we are authenticated.

4. Calling our API from the front-end

The Hanko package is exporting an Hanko class which is the client that allows us to retrieve the session info and listen for some specific events.

In our case we can take advantage of the onAuthFlowCompleted() event, which will be triggered once we complete the login flow.

Inside that event we must invoke our edge function passing the hanko JWT, then we should patch supabase headers in order to put the right Authorization Bearer token.

import { register, Hanko } from "@teamhanko/hanko-elements";
import {createClient} from "supabase";

const supabaseUrl = import.meta.env.VITE_CLIENT_SUPABASE_URL;
const supabaseKey = import.meta.env.VITE_CLIENT_SUPABASE_KEY;
const hankoApi = process.env.REACT_APP_HANKO_API_URL;

const supabase = createClient(supabaseUrl, supabaseKey);
const hanko = new Hanko(hankoApi);

hanko.onAuthFlowCompleted(() => {
  const session = hanko.session.get();
  supabase.functions.invoke(
    'our-function-name', 
    { jwt: session.jwt }
  ).then(result => {
    const token = result.data.token;
    patchSupabaseRestClient(token);
  })
});

export function patchSupabaseRestClient(accessToken: string | null) {
  // βœ… Set functions auth in order to put the jwt token 
  // for edge functions which need authentication
  client.functions.setAuth(accessToken ?? supabaseKey);
  if (accessToken) {
    // βœ… Patching rest headers that will be used 
    // for querying the database through rest.
    client["rest"].headers = {
      ...client.rest.headers,
      Authorization: `Bearer ${accessToken}`,
    };
  } else {
    client["rest"].headers = {
      ...client.rest.headers,
      Authorization: `Bearer ${supabaseKey}`,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

It's done! Now all the calls that will use the supabase rest client to query the db will contain our token containing the user_id.

βœ… The request will contains our custom jwt token
supabase.from("todos").select("*")
Enter fullscreen mode Exit fullscreen mode

πŸ–ŒοΈ Styling Hanko Elements

Hanko Elements exports two useful web components that allows to handle the auth flow and the user profile.

There are several ways to customize
them (docs).

For SpecFlow I followed two approaches:

  • Vanilla-Extract to verride css variables and define all styles via ::part attribute
  • Plain CSS to override some internal elements inside the shadow dom. This is the case for the accordion component in the profile page. Note that I didn't disable the shadow dom since the hanko authors don't recommend it

I tried to follow my UI Kit tokens in order to make something that fits good inside this application.

First, I made a solid component for each web component in order to decouple it's logic, and to extend some behaviors and
the JSX interface, since solid has its own.

Each web component will have attached the custom classes generated by vanilla-extract, and a custom <style> tag inside
the shadow dom which I add once the component is mounted to the dom.

// src/components/auth/HankoAuth.tsx
import * as styles from "./HankoAuth.css";
import {onMount} from "solid-js";
import overrides from "./hanko-auth-overrides.css?raw"; // Get the css text from the file.

export function HankoAuth() {
  let hankoAuth: HTMLElement;

  onMount(() => {
    const styleElement = document.createElement("style");
    styleElement.textContent = overrides;
    hankoAuth.shadowRoot!.appendChild(styleElement);
  });

  return (<hanko-auth ref={(ref) => (hankoAuth = ref!)} class={styles.hankoAuth}/>);
}

type JsxIntrinsicElements = JSX.IntrinsicElements;

declare module "solid-js" {
  namespace JSX {
    interface IntrinsicElements {
      "hanko-auth": JsxIntrinsicElements["hanko-auth"];
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The generated class from vanilla-extract will contain all custom vars and base styles defined through a base class, and
the custom ones needed only for the auth/profile component. Basically, I made the base styles for the "Layout"
components like buttons, forms, headlines etc.

import {style} from '@vanilla-extract/css';
import { themeTokens, themeVars } from "@codeui/kit";

const [hankoTheme, hankoVars] = createTheme({
  brandColor: themeVars.brand,
  dangerColor: "#500f1c",
  buttonCriticalColor: themeVars.critical,
  containerPadding: themeTokens.spacing["6"],
  foregroundColor: themeVars.foreground,
  // Other vars...
});

export const base = style([
  hankoTheme,
  {
    vars: {
      // Overrides hanko vars
      // https://github.com/teamhanko/hanko/tree/main/frontend/elements#css-variables
      "--color": hankoVars.foregroundColor,
      "--brand-color": hankoVars.brandColor,
      // Other vars...
    },
    selectors: {
      // Base layout styles
      "&::part(error)": {
        background: hankoVars.dangerColor,
        color: hankoVars.foregroundColor,
        border: "unset",
        padding: `0 ${themeTokens.spacing["4"]}`,
        gap: themeTokens.spacing["2"],
      },
      // Button styles
      "&::part(button)": {
        border: "none",
        transition: transitions,
      },
      // Input styles
      "&::part(input text-input), &::part(input passcode-input)": {
        padding: `0 ${themeTokens.spacing["4"]}`,
      },
      // Other styles...
    }
  }
]);

export const hankoAuth = style([
  base,
  {
    // custom styles for hanko auth wc...
  }
]);

export const hankoProfile = style([
  base,
  {
    // custom styles for hanko profile wc...
  }
])
Enter fullscreen mode Exit fullscreen mode

The override file will contain only some particular styles that cannot be accessed outside the shadow dom.

/* src/components/Auth/hanko-profile-overrides.css */

.hanko_paragraph:has(h2.hanko_headline) {
    color: var(--paragraph-inner-color);
}
Enter fullscreen mode Exit fullscreen mode

Login Page

πŸš€ Conclusion

In the end, Hanko was a great discovery. It's a new system that rightly doesn't have all the features yet, but it's really promising.

Integrating it and managing the entire server-side part was not complex, the documentation, I repeat, is very well done therefore it is a system accessible to anyone.

Anyway, partecipating in this hackathon reminded me of the times I worked at CodeImage, trying many new technologies and learning many new things.

If you made it this far, thank you 😊. Feedback and/or a star is always appreciated

All related code of SpecFlow it's available on GitHub.
https://github.com/riccardoperra/specflow.

My others projects:

Top comments (0)