Custom OAuth implementation fails in production with Fastify and Bun

Pancakes Rock
Sat, 21 Feb 2026 01:13:28 +0000
Help

Hello everyone,

Recently I decedided to try and roll my own auth for a project because I wanted to learn how to do such. I was toying with OAuth and OIDC and seen that my OAuth works entirely and completely fine locally, but breaks in prod.

## Behavior

Expected And Received Below. Both are the same commit:

Code:

OAuth.ts

```typescript

import { Axios } from “axios”;

import { eq } from “drizzle-orm”;

import { oauthTable } from “./db/schema”;

import { createRemoteJWKSet, jwtVerify } from “jose”;

import { drizzle } from ‘drizzle-orm/neon-http’;

import { neon } from “@neondatabase/serverless”;

const dbURL = process.env.DATABASE_URL as string

const neonSQL = neon(dbURL)

const db = drizzle(neonSQL)

export async function callback(provider: string): Promise<(() => OAuth) | undefined> {

const providerInfo = (await db.select().from(oauthTable).where(eq(oauthTable.provider, provider)))\[0\]

switch (provider) {

    case "discord":

        if (!providerInfo) {

            throw new Error("No Provider Info")

        }



        return () => {

            const secret = process.env.DISCORD_SECRET

            if (!secret) {

                console.log("No Discord Secret")

                process.emitWarning("No Discord Secret")

                process.exit(1)

            }

            return new OAuth(new Axios({ headers: { "User-Agent": "Pancakes Cue System/1" } }), providerInfo.authorizeUrl, providerInfo.clientId, secret, providerInfo.tokenUrl, providerInfo.userInfoUrl ?? "", providerInfo.redirectUri, (data) => {

                return { userId: data.id, email: data.email }

            })

        }

    case "google":

        if (!providerInfo) {

            throw new Error("No Provider Info")

        }

        return () => {

            const secret = process.env.GOOGLE_SECRET

            if (!secret) {

                console.log("No GOOGLE Secret")

                process.emitWarning("No GOOGLE Secret")

                process.exit(1)

            }

            const auth = new OAuth(new Axios({ headers: { "User-Agent": "Pancakes Cue System/1" } }), providerInfo.authorizeUrl, providerInfo.clientId, secret, providerInfo.tokenUrl, providerInfo.userInfoUrl ?? "", providerInfo.redirectUri, async (data, axios) => {

                const jwt = await jwtVerify(data.id_token, createRemoteJWKSet(new URL("https://www.googleapis.com/oauth2/v3/certs")), {

                    issuer: 'https://accounts.google.com',

                    audience: providerInfo.clientId

                })

                if (!jwt.payload.sub || !jwt.payload.email) throw new Error("Invalid JWT Payload");

                return { userId: jwt.payload.sub, email: String(jwt.payload.email) }

            })

            auth.skipUserEndpoint = true
            return auth

        }



    case "auth0":

        if (!providerInfo) {

            throw new Error("No Provider Info")

        }



        return () => {

            const secret = process.env.AUTH0_SECRET

            if (!secret) {

                console.log("No AUTH0 Secret")

                process.emitWarning("No AUTH0 Secret")

                process.exit(1)

            }

            const auth = new OAuth(new Axios({ headers: { "User-Agent": "Pancakes Cue System/1" } }), providerInfo.authorizeUrl, providerInfo.clientId, secret, providerInfo.tokenUrl, providerInfo.userInfoUrl ?? "", providerInfo.redirectUri, async (data, axios) => {

                return { userId: data.sub, email: data.email }

            })

            return auth

        }

    default:

        return undefined



};

}

type parser = (data: any, axios: Axios) => { userId: string; email: string } | Promise<{ userId: string; email: string }>;

type OAuthUser = { userId: string, email: string } | { err: string, error?: any }

export class OAuth {

#parser: parser;

readonly clientId: string;

#clientSecret: string;

#tokenUrl: string;

#userInfoUrl: string;

#redirectUri: string;

#stateSecret: string = "";

#baseUrl: string;

#axiosInstance: Axios;

skipUserEndpoint: boolean = false;

readonly requestId: string = crypto.randomUUID();



constructor(

    axios: Axios,

    authorizationUrl: string,

    clientId: string,

    clientSecret: string,

    tokenUrl: string,

    userInfoUrl: string,

    redirectUri: string,

    parser: parser,

) {

    this.#axiosInstance = axios;

    this.#parser = parser;

    this.clientId = clientId;

    this.#clientSecret = clientSecret;

    this.#tokenUrl = tokenUrl;

    this.#userInfoUrl = userInfoUrl;

    this.#redirectUri = redirectUri;

    this.#baseUrl = authorizationUrl;

}



private async generatePKCE(): Promise<{

    verifier: string;

    challenge: string;

}> {

    const verifier =

        crypto.randomUUID().replace(/-/g, "") +

        crypto.randomUUID().replace(/-/g, "");



    const encoder = new TextEncoder();

    const data = encoder.encode(verifier);

    const digest = await crypto.subtle.digest("SHA-256", data);



    const challenge = btoa(String.fromCharCode(...new Uint8Array(digest)))

        .replace(/\\+/g, "-")

        .replace(/\\//g, "\_")

        .replace(/=+$/, "");



    return { verifier, challenge };

}



public verifyState(state: string): boolean {

    return state === this.#stateSecret

}



public async generateURL(scope: string | string\[\]): Promise<string> {

    this.#stateSecret = crypto.randomUUID();

    const params = new URLSearchParams({

        client_id: this.clientId,

        redirect_uri: this.#redirectUri,

        response_type: "code",

        state: this.#stateSecret,

        scope: Array.isArray(scope) ? scope.join(" ") : scope,

    });

    return \`${this.#baseUrl}?${params}\`;

}



public async getUserInfo(code: string): Promise<OAuthUser> {

    const body = new URLSearchParams({

        grant_type: "authorization_code",

        client_id: this.clientId,

        client_secret: this.#clientSecret,

        code,

        redirect_uri: this.#redirectUri,

    });



    try {

        const tokenRes = await this.#axiosInstance.post(this.#tokenUrl, body.toString());

        const token = JSON.parse(tokenRes.data);

        console.log("Token request body:", body.toString());

        console.log("Token response:", token);

        if (!token.access_token) return { err: "Error Requesting Token", error: token };



        if (!this.skipUserEndpoint) {

            const userRes = await this.#axiosInstance.get(this.#userInfoUrl, {

                headers: {

                    Authorization: \`Bearer ${token.access_token}\`,

                    "Content-Type": "application/x-www-form-urlencoded"

                },

            });

            return this.#parser(JSON.parse(userRes.data), this.#axiosInstance as any);

        } else {

            return this.#parser(token, this.#axiosInstance as any);

        }

    } catch (err: any) {

        return { err: "Auth Request Failed", error: err.response?.data || err.message };

    }

}



public async saveState(redis: Bun.RedisClient) {

    try {

        await redis.set("STATE\_" + this.requestId, this.#stateSecret, "EX", 300)

    } catch (e) {

        console.error("Failed to save OAuth state to Redis:", e)

        throw e

    }

}



public async retrieveState(redis: Bun.RedisClient, id: string): Promise<boolean> {

    const state = await redis.get("STATE\_" + id)

    if (!state) {

        return false

    }

    this.#stateSecret = state

    return true

}

}
```

Routes:

```typescript

api.get(“/oauth/:provider/callback”, async (req, res) => {

const { code, state } = req.query as { code: string; state: string }

const { provider } = req.params as { provider: string }

const { requestId } = req.cookies as { requestId: string }



const providerData = (await db.select().from(oauthTable).where(eq(oauthTable.provider, provider)))\[0\]



if (!providerData) {

    return res.code(400).send({ message: "Invalid Provider" })

}



const oauthFunction = callback(providerData?.provider ?? "")



if (oauthFunction === undefined) {

    return res.code(400).send({ message: "Invalid Provider" })

}

try {

    const authFunction = (await oauthFunction)



    if (!authFunction) {

        return res.code(400).send({ message: "Invalid Provider" })

    }



    const auth = authFunction()



    if (!state) {

        return res.send({ err: "No State" }).code(400)

    }



    const restored = await auth.retrieveState(redis, requestId)



    if (!restored) {

        console.log("Restored?: " + restored)

        console.log("Fetched Redis Response: " + await redis.get("STATE\_" + requestId))

        console.log("State Provided: " + state)

        return res.code(400).send({ err: "Invalid OAuth Session" })

    }



    if (!auth.verifyState(state)) {

        return res.code(400).send({ err: "State Mismatch" })

    }



    const userInfo = await auth.getUserInfo(code)



    if ("err" in userInfo) {

        return res.send(userInfo).code(400)

    }



    let targetUser = (await db.select().from(userTable).where(sql\`${userTable.oauthId} @> ${JSON.stringify(\[{ providerId: providerData.id, userId: userInfo.userId }\])}::jsonb\`))\[0\]



    if (!targetUser) {

        const emailUser = (await db.select().from(userTable).where(eq(userTable.username, userInfo.email)))\[0\]

        if (emailUser) {

            const updatedOauthIds = \[...(emailUser.oauthId ?? \[\]), { providerId: providerData.id, userId: userInfo.userId }\];

            await db.update(userTable)

                .set({ oauthId: updatedOauthIds })

                .where(eq(userTable.id, emailUser.id));

            targetUser = emailUser;

        } else {

            const newUserId = crypto.randomUUID();

            await db.insert(userTable).values({

                id: newUserId,

                username: userInfo.email,

                passwordHash: "OAUTH",

                oauthId: \[{ providerId: providerData.id, userId: userInfo.userId }\]

            });

            targetUser = (await db.select().from(userTable).where(eq(userTable.id, newUserId)))\[0\];

        }

    }



    if (!targetUser) return res.code(500).send({ message: "Failed to create user" });




    // Fetch the user safely

    const getChannelNames = async (userId: string, table: any) => {

        const ids = (await db.select().from(table).where(eq(table.userId, userId)))\[0\]?.channelIds ?? \[\]

        const names: string\[\] = \[\]

        for (const id of ids) {

            const data = (await db.select().from(channelTable).where(eq(channelTable.id, id)))\[0\]

            if (data) names.push(data.name)

        }

        return names

    }



    const sendChannelNames = await getChannelNames(targetUser.id, userSend);

    const receiveChannelNames = await getChannelNames(targetUser.id, userRecieve);



    res.setCookie("token", await createJWT(targetUser.id, receiveChannelNames, sendChannelNames, "12h"), {

        path: "/",

        domain: domain,

        secure: isSecure,

        sameSite: "lax"

    })

} catch (e) {

    console.error(e)

    return res.code(500).send({ message: "Internal Server Error" })

}

res.clearCookie("requestId", { path: "/" })

res.redirect("/cue")

})

api.get(“/oauth/:provider”, async (req, res) => {

const { provider } = req.params as { provider: string }

const providerData = (await db.select().from(oauthTable).where(eq(oauthTable.provider, provider)))\[0\]



if (!providerData) {

    return res.code(400).send({ message: "Invalid Provider" })

}



const axios = new Axios()



const auth = new OAuth(axios, providerData.authorizeUrl, providerData.clientId, "", providerData.tokenUrl, "", providerData.redirectUri, () => { return { userId: "", email: "" } })



const uri = await auth.generateURL(providerData?.scopes)



await auth.saveState(redis)



res.clearCookie("requestId", { path: "/" })

res.setCookie("requestId", auth.requestId, {

    path: "/",

    domain: domain,

    secure: isSecure,

    sameSite: "lax",

})

return res.status(302).redirect(uri)

})

```

I know my implementation is bad. But if you could help that’d be stellar!

And In Vercel. I can see 2 requests, the one that gets sent to the client is the Invalid one, the other is proper and valid.

4 posts - 3 participants