K
Kinde•5mo ago
saM69420

Kinde + Bun + Hono + React

Hey I was just hoping to get some advice/validation on code I've written for using Kinde with a bun and hono app. I'm using the typescript SDK. My session manager is storing all the session items in cookies as the next.js libary does. Then I have some custom middleware for protected routes. My backend is serving up a react app, the cookies get sent with every request, and everything is working there. I have a /api/me endpoint that checks if the user is logged in. The react app calls that endpoint when it first loads to check if the user is logged in.
// auth.ts
import {
createKindeServerClient,
GrantType,
SessionManager,
UserType,
} from "@kinde-oss/kinde-typescript-sdk";

import { Hono, Context, MiddlewareHandler } from "hono";
import { getCookie, setCookie, deleteCookie } from "hono/cookie";

export const kindeClient = createKindeServerClient(
GrantType.AUTHORIZATION_CODE,
{
authDomain: process.env.KINDE_DOMAIN!,
clientId: process.env.KINDE_CLIENT_ID!,
clientSecret: process.env.KINDE_CLIENT_SECRET!,
redirectURL: process.env.KINDE_REDIRECT_URI!,
logoutRedirectURL: process.env.KINDE_LOGOUT_REDIRECT_URI!,
}
);

export const sessionManager = (c: Context): SessionManager => ({
async getSessionItem(key: string) {
const result = getCookie(c, key);
return result;
},
async setSessionItem(key: string, value: unknown) {
if (typeof value === "string") {
setCookie(c, key, value);
} else {
setCookie(c, key, JSON.stringify(value));
}
},
async removeSessionItem(key: string) {
deleteCookie(c, key);
},
async destroySession() {
["id_token", "access_token", "user", "refresh_token"].forEach((key) => {
deleteCookie(c, key);
});
},
});

export const protectRoute: MiddlewareHandler = async (c, next) => {
try {
const manager = sessionManager(c);
const isAuthenticated = await kindeClient.isAuthenticated(manager);
if (!isAuthenticated) {
return c.json({ error: "Unauthorized" }, 401);
}
await next();
} catch (e) {
console.error(e);
return c.json({ error: "Unauthorized" }, 401);
}
};

export const getUser: MiddlewareHandler<{
Variables: {
user: UserType;
};
}> = async (c, next) => {
try {
const manager = sessionManager(c);
const isAuthenticated = await kindeClient.isAuthenticated(manager);
if (!isAuthenticated) {
return c.json({ error: "Unauthorized" }, 401);
}
const profile = await kindeClient.getUserProfile(manager);
c.set("user", profile);
await next();
} catch (e) {
console.error(e);
return c.json({ error: "Unauthorized" }, 401);
}
};

export const authRoutes = new Hono()
.get("/logout", async (c) => {
const logoutUrl = await kindeClient.logout(sessionManager(c));
return c.redirect(logoutUrl.toString());
})
.get("/login", async (c) => {
const loginUrl = await kindeClient.login(sessionManager(c));
return c.redirect(loginUrl.toString());
})
.get("/register", async (c) => {
const registerUrl = await kindeClient.register(sessionManager(c));
return c.redirect(registerUrl.toString());
})
.get("/callback", async (c) => {
await kindeClient.handleRedirectToApp(
sessionManager(c),
new URL(c.req.url)
);
return c.redirect("/");
});
// auth.ts
import {
createKindeServerClient,
GrantType,
SessionManager,
UserType,
} from "@kinde-oss/kinde-typescript-sdk";

import { Hono, Context, MiddlewareHandler } from "hono";
import { getCookie, setCookie, deleteCookie } from "hono/cookie";

export const kindeClient = createKindeServerClient(
GrantType.AUTHORIZATION_CODE,
{
authDomain: process.env.KINDE_DOMAIN!,
clientId: process.env.KINDE_CLIENT_ID!,
clientSecret: process.env.KINDE_CLIENT_SECRET!,
redirectURL: process.env.KINDE_REDIRECT_URI!,
logoutRedirectURL: process.env.KINDE_LOGOUT_REDIRECT_URI!,
}
);

export const sessionManager = (c: Context): SessionManager => ({
async getSessionItem(key: string) {
const result = getCookie(c, key);
return result;
},
async setSessionItem(key: string, value: unknown) {
if (typeof value === "string") {
setCookie(c, key, value);
} else {
setCookie(c, key, JSON.stringify(value));
}
},
async removeSessionItem(key: string) {
deleteCookie(c, key);
},
async destroySession() {
["id_token", "access_token", "user", "refresh_token"].forEach((key) => {
deleteCookie(c, key);
});
},
});

export const protectRoute: MiddlewareHandler = async (c, next) => {
try {
const manager = sessionManager(c);
const isAuthenticated = await kindeClient.isAuthenticated(manager);
if (!isAuthenticated) {
return c.json({ error: "Unauthorized" }, 401);
}
await next();
} catch (e) {
console.error(e);
return c.json({ error: "Unauthorized" }, 401);
}
};

export const getUser: MiddlewareHandler<{
Variables: {
user: UserType;
};
}> = async (c, next) => {
try {
const manager = sessionManager(c);
const isAuthenticated = await kindeClient.isAuthenticated(manager);
if (!isAuthenticated) {
return c.json({ error: "Unauthorized" }, 401);
}
const profile = await kindeClient.getUserProfile(manager);
c.set("user", profile);
await next();
} catch (e) {
console.error(e);
return c.json({ error: "Unauthorized" }, 401);
}
};

export const authRoutes = new Hono()
.get("/logout", async (c) => {
const logoutUrl = await kindeClient.logout(sessionManager(c));
return c.redirect(logoutUrl.toString());
})
.get("/login", async (c) => {
const loginUrl = await kindeClient.login(sessionManager(c));
return c.redirect(loginUrl.toString());
})
.get("/register", async (c) => {
const registerUrl = await kindeClient.register(sessionManager(c));
return c.redirect(registerUrl.toString());
})
.get("/callback", async (c) => {
await kindeClient.handleRedirectToApp(
sessionManager(c),
new URL(c.req.url)
);
return c.redirect("/");
});
// app.ts
import { Hono } from "hono";
import { serveStatic } from "hono/bun";

import { authRoutes, getUser } from "./auth";
import expenseRoute from "./expenses";

const app = new Hono();

const apiRoutes = app
.basePath("/api")
.route("/expenses", expenseRoute)
.get("/me", getUser, async (c) => {
const user = await c.var.user;
return c.json({ user });
});

app.route("/", authRoutes);

// app.use('/favicon.ico', serveStatic({ path: './favicon.ico' }))
app.get("*", serveStatic({ root: "./frontend/dist" }));
app.get("*", serveStatic({ path: "./frontend/dist/index.html" }));

export default app;
export type ApiRoutes = typeof apiRoutes;
// app.ts
import { Hono } from "hono";
import { serveStatic } from "hono/bun";

import { authRoutes, getUser } from "./auth";
import expenseRoute from "./expenses";

const app = new Hono();

const apiRoutes = app
.basePath("/api")
.route("/expenses", expenseRoute)
.get("/me", getUser, async (c) => {
const user = await c.var.user;
return c.json({ user });
});

app.route("/", authRoutes);

// app.use('/favicon.ico', serveStatic({ path: './favicon.ico' }))
app.get("*", serveStatic({ root: "./frontend/dist" }));
app.get("*", serveStatic({ path: "./frontend/dist/index.html" }));

export default app;
export type ApiRoutes = typeof apiRoutes;
8 Replies
Oli - Kinde
Oli - Kinde•5mo ago
Hey @saM69420, Thanks for reaching out. Are you able to explain more on what a "bun and hono app" is? Also just checking you aren't experiencing any issue and you just want us to validate your code/approach?
saM69420
saM69420•5mo ago
No issues, I just want to make sure i'm not shooting myself in the foot or causing any security issues. This is for a tutorial so I don't want to give people bad advice bun is the typescript runtime instead of node.js hono is the backend framework instead of express
Oli - Kinde
Oli - Kinde•5mo ago
We are here to help anytime you need some sense-checking on your code, especially for any security vulnerabilities. Also thanks for elaborating on bun and hono. I have passed your code to an teammate of mine who is an expert on TypeScript and Node.js Loved your last Kinde video by the way, thanks for much for the support you give us.
saM69420
saM69420•5mo ago
thank you 😊
Oli - Kinde
Oli - Kinde•5mo ago
No worries.
leo_kinde
leo_kinde•5mo ago
Looks good to me @saM69420 , one suggestion would be to set httpOnly on the cookies to prevent them being accessed potentially by cross-site scripts client-side. For security, secure and sameSite are also good to set if appropriate for your app.
saM69420
saM69420•5mo ago
Thanks @leo_kinde I've updated the cookie options. It doesn't work with Strict, so I set it to Lax. I guess that's because of the way the callback redirect works upon successful sign in.
export const sessionManager = (c: Context): SessionManager => ({
async setSessionItem(key: string, value: unknown) {
const cookieOptions = {
httpOnly: true,
secure: true,
sameSite: "Lax",
} as const;
if (typeof value === "string") {
setCookie(c, key, value, cookieOptions);
} else {
setCookie(c, key, JSON.stringify(value), cookieOptions);
}
},
// ...
});
export const sessionManager = (c: Context): SessionManager => ({
async setSessionItem(key: string, value: unknown) {
const cookieOptions = {
httpOnly: true,
secure: true,
sameSite: "Lax",
} as const;
if (typeof value === "string") {
setCookie(c, key, value, cookieOptions);
} else {
setCookie(c, key, JSON.stringify(value), cookieOptions);
}
},
// ...
});
Then client side, my react app is using react query to grab the user details from the server and keeps them i'm memory for Infinity which works since logout and login require complete redirects anyway.
import api from "@/lib/api";
import { queryOptions } from "@tanstack/react-query";

async function authenticatedUser() {
const res = await api.me.$get();
if (!res.ok) {
throw new Error("Network response was not ok");
}
const data = await res.json();
return data.user;
}

export const userQueryOptions = queryOptions({
queryKey: ["user-me"],
queryFn: () => authenticatedUser(),
staleTime: Infinity,
});
import api from "@/lib/api";
import { queryOptions } from "@tanstack/react-query";

async function authenticatedUser() {
const res = await api.me.$get();
if (!res.ok) {
throw new Error("Network response was not ok");
}
const data = await res.json();
return data.user;
}

export const userQueryOptions = queryOptions({
queryKey: ["user-me"],
queryFn: () => authenticatedUser(),
staleTime: Infinity,
});
Then any component can get the user's details by performing the query that's already been cached.
import { Button } from "@/components/ui/button";

import { userQueryOptions } from "@/lib/user-query";
import { useQuery } from "@tanstack/react-query";

export default function ProfilePage() {

const {data: user} = useQuery(userQueryOptions);

return (
<div className="flex flex-col gap-y-4 items-center">
<h1 className="text-4xl font-bold">Hi {user?.given_name}</h1>
<div className="text-2xl font-bold">{user?.email}</div>
<Button asChild>
<a href="/logout">Logout</a>
</Button>
</div>
);
}
import { Button } from "@/components/ui/button";

import { userQueryOptions } from "@/lib/user-query";
import { useQuery } from "@tanstack/react-query";

export default function ProfilePage() {

const {data: user} = useQuery(userQueryOptions);

return (
<div className="flex flex-col gap-y-4 items-center">
<h1 className="text-4xl font-bold">Hi {user?.given_name}</h1>
<div className="text-2xl font-bold">{user?.email}</div>
<Button asChild>
<a href="/logout">Logout</a>
</Button>
</div>
);
}
So no extra context providers and no client side processing of the tokens. I would love any suggestions if there's room for improvement with any of this. Im trying to keep it as simple and robust as possible while only using the Kinde typescript SDK on the backend.
leo_kinde
leo_kinde•5mo ago
Sounds like a reasonable approach @saM69420 , if the page is long lived with lots of interactivity, it could be worth rechecking auth periodically, but might not be relevant depending on the app.