Kinde on Astro

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.