SpecFlow - Integrating Hanko and Supabase in a Solid.js client-side application
Tags: hanko, solidjs, supabase, hackathon
Published: 2023-10-28 19:22:37

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](https://www.hanko.io/hackathon) to build SpecFlow: a tool which allows you to centralize all your project specs and documentation.

### What's Hanko?
From their [GitHub](https://github.com/teamhanko/hanko):
> 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 

{% embed https://github.com/riccardoperra/specflow %}

App

{% embed https://specflow.netlify.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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/thxk25gix6oiqvsxmk5f.gif)

_Passcode challenge_

![Passcode challenge login](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/wonqka24idc8qbqresla.png)

_Dashboard_

![Dashboard](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hkpjht042luqkbk4h511.png)

_Project page - Markdown editor_

![Project page - Markdown editor](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/2til35hhn9d296slf5k5.png)

_Project page - Diagrams editor_

![Project page - Diagrams editor](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/qm2yjix8sxmvz8l4tdzj.png)

_Project page - Exports a diagram_

![Project page - Exports a diagram](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/wy2lgatwpcujpqketzp9.png)

_Profile page_

![Profile page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/il0zld6389izzk52r2d8.png)

## 🤖 Tech stack 

SpecFlow is a client-side SPA built using [SolidJS](https://github.com/solidjs/solid) as a front-end framework, [Supabase](https://supabase.com) for database, realtime, edge functions...and [Hanko](https://hanko.io) to handle the authentication.

All styles have been made with Vanilla-Extract and Tailwind integrating a design system I used for [CodeImage](https://github.com/riccardoperra/codeimage) which is a wrapper of [Kobalte](https://kobalte.dev/docs/core/overview/introduction)

To manage the editor I used [CodeMirror6](https://codemirror.net/) and [Tiptap](https://tiptap.dev), 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](https://mswjs.io/) 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.

```bash
pnpm add @teamhanko/hanko-elements
```

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

```dotenv
VITE_HANKO_API_URL=https://f4****-4802-49ad-8e0b-3d3****ab32.hanko.io
```

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.

```typescript
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"];
    }
  }
}
```

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](https://supabase.com/docs/guides/auth/row-level-security) 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.

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

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

```sql
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;
```

### 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](https://start.solidjs.com/getting-started/what-is-solidstart)

You can read more info about jwt [here](https://jwt.io/introduction).

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.

```typescript
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}))
})
```

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.

```ts
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}`,
    };
  }
}
```

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.

```typescript
✅ The request will contains our custom jwt token
supabase.from("todos").select("*")
```

## 🖌️ 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](https://github.com/teamhanko/hanko/blob/main/frontend/elements/README.md)).

For SpecFlow I followed two approaches:

- [Vanilla-Extract](https://vanilla-extract.style/) to verride css variables and define all styles via [::part attribute](https://developer.mozilla.org/en-US/docs/Web/CSS/::part)
- 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.

```tsx
// 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"];
    }
  }
}
```

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.

```ts
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...
  }
])
```

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

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

.hanko_paragraph:has(h2.hanko_headline) {
    color: var(--paragraph-inner-color);
}
```

![Login Page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/thxk25gix6oiqvsxmk5f.gif)



## 🚀 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:
- [CodeImage](https://github.com/riccardoperra/codeimage)
- [Solid CodeMirror](https://github.com/riccardoperra/solid-codemirror)
- [StateBuilder](https://github.com/riccardoperra/statebuilder)