13 May 2024
Kinde on Astro
Hi all!
I'm super keen to show you how I integrated Kinde authentication into an Astro site. It is super quick, server-side authentication that is a breeze to manage.
I used Kinde's OAuth 2.0 capabilities to manage authentication and access control.
First, create a backend application on Kinde, and set the .env settings with it's credentials.
PUBLIC_KINDE_DOMAIN=***
PUBLIC_KINDE_CLIENT_ID=***
PUBLIC_KINDE_REDIRECT_URI=***
PUBLIC_KINDE_LOGOUT_REDIRECT_URI=***
PUBLIC_KINDE_CLIENT_SECRET=***
In our pages/login.astro file, we create a unique state for each authentication request to prevent CSRF attacks. We then construct the Kinde login URL with necessary parameters like client_id, redirect_uri, and the scopes we need (openid, profile, email). Finally, we redirect the user to this URL.
---
export function generateRandomState(length: number = 16): string {
const characters =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
return Array.from(
{ length },
() => characters[Math.floor(Math.random() * characters.length)],
).join("");
}
const state = generateRandomState();
const kindeLoginUrl = `${import.meta.env.PUBLIC_KINDE_DOMAIN}/oauth2/auth?response_type=code&client_id=${import.meta.env.PUBLIC_KINDE_CLIENT_ID}&redirect_uri=${import.meta.env.PUBLIC_KINDE_REDIRECT_URI}&scope=openid%20profile%20email&state=${state}`;
return Astro.redirect(kindeLoginUrl);
---
Once the user authenticates and Kinde redirects them back, our pages/callback.astro takes over. We extract the code from the URL, which we exchange for an access token at Kinde's token endpoint. If successful, we store this token in a secure, HTTP-only cookie and redirect the user to the dashboard.
---
import fetch from "node-fetch";
interface TokenResponse {
access_token: string;
}
async function handleKindeCallback(): Promise<Response | void> {
const url = new URL(Astro.request.url, "http://example.com");
const code = url.searchParams.get("code");
if (!code) {
return Astro.redirect("/error");
}
const tokenEndpoint = `${import.meta.env.PUBLIC_KINDE_DOMAIN}/oauth2/token`;
const body = new URLSearchParams({
client_id: import.meta.env.PUBLIC_KINDE_CLIENT_ID,
client_secret: import.meta.env.PUBLIC_KINDE_CLIENT_SECRET,
code,
grant_type: "authorization_code",
redirect_uri: import.meta.env.PUBLIC_KINDE_REDIRECT_URI,
});
const tokenResponse = await fetch(tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
if (!tokenResponse.ok) {
return Astro.redirect("/error");
}
const { access_token }: TokenResponse =
(await tokenResponse.json()) as TokenResponse;
Astro.cookies.set("access_token", access_token, {
httpOnly: true,
path: "/",
maxAge: 3600,
});
return Astro.redirect("/dashboard");
}
return await handleKindeCallback();
---
Finally, in pages/index.astro, we first check for the access token in the user's cookies. If present, we verify the token using Kinde's public keys (fetched from .well-known/jwks). Post verification, we fetch the user's profile details from Kinde using the access token.
---
import jwt from "jsonwebtoken";
import { promisify } from "util";
import jwksClient from "jwks-rsa";
import axios from "axios";
import type { JwtHeader, SigningKeyCallback } from "jsonwebtoken";
import type { GetPublicKeyOrSecret } from "jsonwebtoken";
// Setup the JWKS client
const client = jwksClient({
jwksUri: `${import.meta.env.PUBLIC_KINDE_DOMAIN}/.well-known/jwks`,
});
interface DecodedToken {
given_name: string;
email: string;
}
const getKey: GetPublicKeyOrSecret = (
header: JwtHeader,
callback: SigningKeyCallback,
) => {
client.getSigningKey(header.kid, (err, key) => {
if (err || !key) {
callback(err || new Error("Signing key not found"), undefined);
} else {
const signingKey = key.getPublicKey();
callback(null, signingKey);
}
});
};
const verifyToken = (token: string): Promise<DecodedToken | null> => {
return new Promise((resolve, reject) => {
jwt.verify(token, getKey, { algorithms: ["RS256"] }, (err, decoded) => {
if (err) {
console.error("Token verification failed:", err);
return resolve(null);
}
resolve(decoded as DecodedToken);
});
});
};
async function fetchUserDetails(accessToken: string) {
try {
const response = await axios.get(
"https://*.kinde.com/oauth2/v2/user_profile",
{
headers: { Authorization: `Bearer ${accessToken}` },
},
);
return response.data;
} catch (error: any) {
console.error(
"Failed to fetch user details:",
error.response?.statusText,
);
return null;
}
}
const accessTokenCookie = Astro.cookies.get("access_token");
const accessToken = accessTokenCookie ? accessTokenCookie.value : null;
const userDetails = accessToken ? await verifyToken(accessToken) : null;
const kindeUserDetails = accessToken
? await fetchUserDetails(accessToken)
: null;
---
We can use these to display the user details, or any of the other OpenID Connect endpoints!
{
kindeUserDetails ? (
<div>
<h1>Welcome {kindeUserDetails.name}!</h1>
<p>Email: {kindeUserDetails.email}</p>
<a href="/api/auth/logout">Log out</a>
</div>
) : (
<p>
Please <a href="/login">log in</a> to view your profile.
</p>
)
}
There - an auth system in 141 lines. Pretty neat!
I hope you are all doing well readers!
Jacob
EDIT: I added support for Dev/Production Mode, Cloudflare support, and higher performance and a lower build size in even less lines - check out the Gist.