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.

