Skip to content

Latest commit

 

History

History
431 lines (341 loc) · 16.6 KB

README.md

File metadata and controls

431 lines (341 loc) · 16.6 KB

SpecFlow

Netlify Status Made with Hanko Made with SolidJS Made with Vanilla Extract
Made with 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

Read the integration post:

https://dev.to/riccardoperra/specflow-integrating-hanko-and-supabase-in-a-solidjs-client-side-application-550m

💡 Features

  • ✅ SpecFlow provides you with a single hub to organize and centralize all your project specs and documentation. No more endless searching; everything you need is just a click away.
  • ✅ Write your project notes, requirements, and specifications using a Markdown-like interface.
  • ✅ Write and export diagrams such as sequence diagrams, ER, Mind maps etc complaint to Mermaid syntax.
  • ✅ AI-Powered Assistance: with SpecFlow, you can harness the power of AI to effortlessly generate content, saving you time and effort.

    [!WARNING] Currently for the hackathon it is only possible to generate mermaid diagrams using my personal OpenAI key which has a limit usage.

  • ✅ Collaborative Team Sharing (Work in Progress): Soon, SpecFlow will enable sharing of project pages with all team members. This feature ensures that everyone has access to the essential information they need to excel in their roles.

Homepage of SpecFlow

SpecFlow project page markdown view

🤖 Tech stack

SpecFlow tech stack is mainly composed by these technologies:

Other libraries that I should mention:

Local development

Follow the local_development.md guide for more information.

🔐 Hanko integration details

SpecFlow is a single-page application which integrates Hanko for authentication. All related code which handles the authentication is in these files/folders:

  • auth.ts: Handles auth state and sync with supabase instance
  • src/components/Auth: Auth page and profile component using hanko element using Vanilla Extract for custom styling

Authentication flow

Supabase Database comes with a useful RLS policy which allows to restrict data access using custom rules. Since we need that each user can operate only inside their projects, we need to somehow make supabase understand who is making the requests.

Hanko is replacing supabase traditional auth (which is disabled), so after the sign-in from the UI we need to extract the data we need from Hanko's JWT, and sign our own to send to Supabase.

We can do that using hanko authFlowCompleted event, which gets called once the user authenticates through the UI.

hanko.onAuthFlowCompleted(() => {
  const session = hanko.session.get();
  supabase.functions.invoke("hanko-auth", {body: {token: session.jwt}});
});

During that event we will call the supabase edge function in supabase/functions/hanko-auth which will validate the Hanko JWT retrieving the JWKS config, then sign ourselves a new token for supabase.

import * as jose from "https://deno.land/x/[email protected]/index.ts";

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) // We'll set the same expiration time as Hanko token
  .setProtectedHeader({alg: "HS256"})
  .sign(secret);

Our payload for the JWT will contain the user's identifier from Hanko and the same expiration date.

Important

We are signing this JWT using Supabase's signing secret token, so we'll be able to check the jwt authenticity. This is a crucial step which obviously for security reasons cannot be done on the client side.

Once that each supabase fetch call should include our custom token which contains the Hanko userId.

import {createClient} from "supabase";

const supabaseUrl = import.meta.env.VITE_CLIENT_SUPABASE_URL;
const supabaseKey = import.meta.env.VITE_CLIENT_SUPABASE_KEY;

const client = createClient(supabaseUrl, supabaseKey);

// Get headers after creating the supabase client. 
// The authorization bearer is valuated with the `supabaseKey`
const originalHeaders = structuredClone(client.rest.headers);

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 = originalHeaders;
  }
}

Next, thanks to a postgres function we can extract the userId from the 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;

We can now define a new RLS using our hanko user_id retrieved through our custom function.

CREATE
POLICY "Allows all operations" ON public.project_page
    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.project_page ENABLE ROW LEVEL SECURITY;

The supabase database schema is up through the initial migration which will define all functions, tables and rls.

20231020190554_schema_init.sql.

You can find all others migrations here.

Here a sequence diagram of an in-depth detail of the client side authentication flow (made through SpecFlow 😉)

sequenceDiagram
    participant Client
    participant Hanko Server
    participant Supabase Edge Functions
    participant Supabase Database
    Client ->> Hanko Server: Authentication Flow
    activate Client
    activate Hanko Server
    Hanko Server -->> Client: Set user session and "hanko" cookie
    deactivate Hanko Server
    Client ->> Supabase Edge Functions: /functions/v1/hanko-auth
    activate Supabase Edge Functions
    Note right of Client: Pass session info like jwt, userId
    Supabase Edge Functions ->> Hanko Server: Retrieves JWKS configuration
    activate Hanko Server
    Note over Supabase Edge Functions, Hanko Server: <hankoUrl>/.well-known/jwks.json
    Hanko Server -->> Supabase Edge Functions: Returns configuration
    deactivate Hanko Server
    activate Supabase Edge Functions
    Supabase Edge Functions ->> Supabase Edge Functions: Verify Hanko jwt token
    Supabase Edge Functions ->> Supabase Edge Functions: Sign new jwt token containing the hanko user_id.
    Note right of Supabase Edge Functions: Signing a token for supabase is needed to integrate the db RLS policies.
    Supabase Edge Functions -->> Client: Returns access token for supabase
    deactivate Supabase Edge Functions
    deactivate Supabase Edge Functions
    activate Client
    Client ->> Client: Patch supabase client Authorization header
    Client ->> Client: Set "sb-token" session cookie
    Client ->> Supabase Database: Call /rest/ api to do some operations
    note over Client, Supabase Database: Will pass the token received from the supabase edge function
    deactivate Client
Loading

Mocking Hanko for local development

SpecFlow integrates the latest version of MockServiceWorker to mock locally the entire Hanko authentication flow.

The mocking handlers are written in src/mocks/hanko-handlers.ts file.

When the environment variable VITE_ENABLE_AUTH_MOCK is true, MSW will be initialized in order to login with two different users.

Currently both passcode and password flows are mocked, you can toggle them by updating the ENABLE_PASSCODE_FLOW constant in src/mocks/hanko-handlers.ts.

🖌️ Styling Hanko

Hanko Elements come with 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 chose 2 approaches to manage customizations:

  • Vanilla-Extract: Used to override css variables and define styles via ::part attribute
  • Plain CSS: Used to override some internal elements by classes inside the shadow dom. Note that I didn't disable the shadow dom since the hanko authors doesn't recommend it

Most of the styles will use part of CodeUI kit variables so that it integrates into the application ui in the best way.

Files

Details

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 a custom class generated by vanilla-extract, then 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 GlobalJsx = JSX.IntrinsicElements;

declare module "solid-js" {
  namespace JSX {
    interface IntrinsicElements {
      "hanko-auth": GlobalJsx["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.

export const base = style([
  hankoTheme,
  {
    vars: {
      "--color": hankoVars.foregroundColor,
      // 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,
      },
      // Other styles...
    }
  }
]);

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

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

Then 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);
}

Here's the final result:

Login Page - Email

Login Page - Email Insert

Login Page - Passcode challenge

Login Page - Passcode challenge.

Profile Page

Profile Page Dialog.

License

MIT © Riccardo Perra