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
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.
Read the integration post:
💡 Features
- ✅…
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
Passcode challenge
Dashboard
Project page - Markdown editor
Project page - Diagrams editor
Project page - Exports a diagram
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
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
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"];
}
}
}
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;
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;
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}))
})
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}`,
};
}
}
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("*")
🖌️ 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"];
}
}
}
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...
}
])
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);
}
🚀 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)