timmm
timmm4mo ago

Non OpenID Connect auth

I'm having some trouble getting custom auth working. I'm trying to get it working with privy.io The local provider seems to be set up correctly - and the app id and domain appear to be correct in the auth.config.ts file I believe what's happening is privy's JWKS endpoint doesn't match - which I think means privy isn't openid connect compatible Is there any way to work around this?
10 Replies
Convex Bot
Convex Bot4mo ago
Thanks for posting in <#1088161997662724167>. Reminder: If you have a Convex Pro account, use the Convex Dashboard to file support tickets. - Provide context: What are you trying to achieve, what is the end-user interaction, what are you seeing? (full error message, command output, etc.) - Use search.convex.dev to search Docs, Stack, and Discord all at once. - Additionally, you can post your questions in the Convex Community's <#1228095053885476985> channel to receive a response from AI. - Avoid tagging staff unless specifically instructed. Thank you!
timmm
timmmOP3mo ago
I ended up getting this working by creating my own openid connect compaitible api using convex http (well known, jwks endpoint etc) and then using a token conversion endpoint to create a session from the privy token and return a token for my application to use for ongoing auth
erquhart
erquhart3mo ago
Nice! Thanks for sharing how you worked through it!
albert chen
albert chen2mo ago
I also ran into this issue earlier. It’s not just the jwks endpoint but also their iss is missing https:// so it was getting rejected by the openidconnect rust library. Did you do all of this from using httpActions or did you host it yourself separately?
timmm
timmmOP2mo ago
http actions Give me an hour and I’ll find the code and share it here @albert chen here's how I built it auth.config.ts
export default {
providers: [
{
domain: process.env.CONVEX_HTTP_API_URL,
applicationID: process.env.PRIVY_APP_ID!,
},
],
};
export default {
providers: [
{
domain: process.env.CONVEX_HTTP_API_URL,
applicationID: process.env.PRIVY_APP_ID!,
},
],
};
auth.ts
const addCorsHeaders = (headers: Record<string, string> = {}) => ({
...headers,
"Access-Control-Allow-Origin":
process.env.SITE_URL ?? "http://localhost:3000",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
Vary: "Origin",
});

export const tokenConversionPreflight = httpAction(async (ctx, request) => {
return new Response(null, {
status: 204,
headers: {
...addCorsHeaders(),
"Access-Control-Max-Age": "86400", // 24 hours
},
});
});

export const jwks = httpAction(async (ctx, request) => {
const jwks = await ctx.runAction(internal.authNode.getJwks);
return new Response(JSON.stringify(jwks), {
status: 200,
headers: addCorsHeaders({
"Content-Type": "application/jwk-set+json",
"Cache-Control": "no-store",
Pragma: "no-cache",
}),
});
});

export const wellKnown = httpAction(async (ctx, request) => {
return new Response(
JSON.stringify({
issuer: CONVEX_HTTP_API_URL,
jwks_uri: `${CONVEX_HTTP_API_URL}/jwks`,
id_token_signing_alg_values_supported: ["RS256"],
subject_types_supported: ["public"],
}),
{
status: 200,
headers: addCorsHeaders({ "Content-Type": "application/json" }),
}
);
});
const addCorsHeaders = (headers: Record<string, string> = {}) => ({
...headers,
"Access-Control-Allow-Origin":
process.env.SITE_URL ?? "http://localhost:3000",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
Vary: "Origin",
});

export const tokenConversionPreflight = httpAction(async (ctx, request) => {
return new Response(null, {
status: 204,
headers: {
...addCorsHeaders(),
"Access-Control-Max-Age": "86400", // 24 hours
},
});
});

export const jwks = httpAction(async (ctx, request) => {
const jwks = await ctx.runAction(internal.authNode.getJwks);
return new Response(JSON.stringify(jwks), {
status: 200,
headers: addCorsHeaders({
"Content-Type": "application/jwk-set+json",
"Cache-Control": "no-store",
Pragma: "no-cache",
}),
});
});

export const wellKnown = httpAction(async (ctx, request) => {
return new Response(
JSON.stringify({
issuer: CONVEX_HTTP_API_URL,
jwks_uri: `${CONVEX_HTTP_API_URL}/jwks`,
id_token_signing_alg_values_supported: ["RS256"],
subject_types_supported: ["public"],
}),
{
status: 200,
headers: addCorsHeaders({ "Content-Type": "application/json" }),
}
);
});
cont.
export const tokenConversionHttp = httpAction(async (ctx, request) => {
const ipAddress = getClientIp(request);
await rateLimiter.limit(ctx, "tokenConversion", { key: ipAddress });

const authHeader = request.headers.get("Authorization");
if (!authHeader?.startsWith("Bearer ")) {
throw new ConvexError("Missing or invalid Authorization header");
}
const privyToken = authHeader.slice(7);

try {
const verifiedClaims = await privy.verifyAuthToken(privyToken);
const privyTokenExpiry = verifiedClaims.expiration;
const adjustedExpiry = privyTokenExpiry - EXPIRY_BUFFER_SECONDS;

let userId =
(await ctx.runQuery(internal.auth.getUser, {
privyUserId: verifiedClaims.userId,
})) ??
(await ctx.runMutation(internal.auth.createUser, {
privyUserId: verifiedClaims.userId,
}));

const existingSession = await ctx.runQuery(
internal.auth.getSessionByPrivyToken,
{ privyToken }
);

if (existingSession && existingSession.expiresAt > Date.now()) {
return new Response(
JSON.stringify({ token: existingSession.cashYapsToken }),
{
status: 200,
headers: addCorsHeaders({ "Content-Type": "application/json" }),
}
);
} else if (existingSession) {
await ctx.runMutation(internal.auth.deleteSession, {
sessionId: existingSession._id,
});
}
export const tokenConversionHttp = httpAction(async (ctx, request) => {
const ipAddress = getClientIp(request);
await rateLimiter.limit(ctx, "tokenConversion", { key: ipAddress });

const authHeader = request.headers.get("Authorization");
if (!authHeader?.startsWith("Bearer ")) {
throw new ConvexError("Missing or invalid Authorization header");
}
const privyToken = authHeader.slice(7);

try {
const verifiedClaims = await privy.verifyAuthToken(privyToken);
const privyTokenExpiry = verifiedClaims.expiration;
const adjustedExpiry = privyTokenExpiry - EXPIRY_BUFFER_SECONDS;

let userId =
(await ctx.runQuery(internal.auth.getUser, {
privyUserId: verifiedClaims.userId,
})) ??
(await ctx.runMutation(internal.auth.createUser, {
privyUserId: verifiedClaims.userId,
}));

const existingSession = await ctx.runQuery(
internal.auth.getSessionByPrivyToken,
{ privyToken }
);

if (existingSession && existingSession.expiresAt > Date.now()) {
return new Response(
JSON.stringify({ token: existingSession.cashYapsToken }),
{
status: 200,
headers: addCorsHeaders({ "Content-Type": "application/json" }),
}
);
} else if (existingSession) {
await ctx.runMutation(internal.auth.deleteSession, {
sessionId: existingSession._id,
});
}
cont.
// Use internal action directly instead of HTTP request
const appToken = await ctx.runAction(internal.authNode.signToken, {
payload: {
iss: CONVEX_HTTP_API_URL,
sub: verifiedClaims.userId,
aud: PRIVY_APP_ID,
exp: adjustedExpiry,
},
});

await ctx.runMutation(internal.auth.storeSession, {
userId,
privyTokenIdentifier: verifiedClaims.userId,
privyToken,
appToken,
expiresAt: Date.now() + 60 * 60 * 1000,
});

return new Response(JSON.stringify({ token: cashYapsToken }), {
status: 200,
headers: addCorsHeaders({ "Content-Type": "application/json" }),
});
} catch (error) {
console.error("Token conversion error:", error);
return new Response(JSON.stringify({ error: "Failed to convert token" }), {
status: 401,
headers: addCorsHeaders({ "Content-Type": "application/json" }),
});
}
});
// Use internal action directly instead of HTTP request
const appToken = await ctx.runAction(internal.authNode.signToken, {
payload: {
iss: CONVEX_HTTP_API_URL,
sub: verifiedClaims.userId,
aud: PRIVY_APP_ID,
exp: adjustedExpiry,
},
});

await ctx.runMutation(internal.auth.storeSession, {
userId,
privyTokenIdentifier: verifiedClaims.userId,
privyToken,
appToken,
expiresAt: Date.now() + 60 * 60 * 1000,
});

return new Response(JSON.stringify({ token: cashYapsToken }), {
status: 200,
headers: addCorsHeaders({ "Content-Type": "application/json" }),
});
} catch (error) {
console.error("Token conversion error:", error);
return new Response(JSON.stringify({ error: "Failed to convert token" }), {
status: 401,
headers: addCorsHeaders({ "Content-Type": "application/json" }),
});
}
});
auth.node.ts
export const signToken = internalAction({
args: {
payload: v.any(),
},
returns: v.string(),
handler: async (ctx, args) => {
// Convert JWK to private key
const jwk = JSON.parse(process.env.CONVEX_PRIVATE_JWK!);
const privateKey = createPrivateKey({ key: jwk, format: "jwk" });

// Create the JWT header and payload
const header = {
alg: "RS256",
typ: "JWT",
kid: "key-1",
};
const now = Math.floor(Date.now() / 1000);
const finalPayload = {
...args.payload,
iat: now,
exp: now + 3600, // 1 hour
};

// Create JWT parts
const encodedHeader = Buffer.from(JSON.stringify(header)).toString(
"base64url"
);
const encodedPayload = Buffer.from(JSON.stringify(finalPayload)).toString(
"base64url"
);
const signingInput = `${encodedHeader}.${encodedPayload}`;

// Sign the token
const signer = createSign("RSA-SHA256");
signer.update(signingInput);
const signature = signer.sign(privateKey, "base64url");

// Combine all parts
return `${signingInput}.${signature}`;
},
});

export const getJwks = internalAction({
args: {},
returns: v.object({
keys: v.array(v.any()),
}),
handler: async (ctx) => {
const publicJwk = JSON.parse(process.env.CONVEX_PUBLIC_JWK!);
return { keys: [publicJwk] };
},
});
export const signToken = internalAction({
args: {
payload: v.any(),
},
returns: v.string(),
handler: async (ctx, args) => {
// Convert JWK to private key
const jwk = JSON.parse(process.env.CONVEX_PRIVATE_JWK!);
const privateKey = createPrivateKey({ key: jwk, format: "jwk" });

// Create the JWT header and payload
const header = {
alg: "RS256",
typ: "JWT",
kid: "key-1",
};
const now = Math.floor(Date.now() / 1000);
const finalPayload = {
...args.payload,
iat: now,
exp: now + 3600, // 1 hour
};

// Create JWT parts
const encodedHeader = Buffer.from(JSON.stringify(header)).toString(
"base64url"
);
const encodedPayload = Buffer.from(JSON.stringify(finalPayload)).toString(
"base64url"
);
const signingInput = `${encodedHeader}.${encodedPayload}`;

// Sign the token
const signer = createSign("RSA-SHA256");
signer.update(signingInput);
const signature = signer.sign(privateKey, "base64url");

// Combine all parts
return `${signingInput}.${signature}`;
},
});

export const getJwks = internalAction({
args: {},
returns: v.object({
keys: v.array(v.any()),
}),
handler: async (ctx) => {
const publicJwk = JSON.parse(process.env.CONVEX_PUBLIC_JWK!);
return { keys: [publicJwk] };
},
});
then in react
const TOKEN_COOKIE = "my_app_token";

function usePrivyAuthProvider() {
const { authenticated, getAccessToken, ready } = usePrivy();

const { reauthorize } = useOAuthTokens({
onOAuthTokenGrant: async ({ oAuthTokens, user }) => {
const token = await getAccessToken();
if (oAuthTokens.provider === "twitter") {
await fetch(
`${import.meta.env.VITE_CONVEX_HTTP_API_URL}/set-twitter-tokens`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
twitterOAuthToken: oAuthTokens.accessToken,
twitterRefreshToken: oAuthTokens.refreshToken,
}),
}
);
}
},
});

// Convert these to refs since they're just used for internal tracking
const isRefreshingRef = React.useRef(false);
const lastPrivyTokenRef = React.useRef<string | null>(null);

const fetchAndStoreToken = useCallback(async (privyToken: string) => {
try {
const response = await fetch(
`${import.meta.env.VITE_CONVEX_HTTP_API_URL}/token-conversion`,
{
method: "POST",
headers: {
Authorization: `Bearer ${privyToken}`,
},
}
);
const data = (await response.json()) as { token: string };

Cookies.set(TOKEN_COOKIE, data.token, {
expires: new Date(Date.now() + 55 * 60 * 1000),
secure: true,
sameSite: "strict",
});

return data.token;
} catch (error) {
console.error("Error fetching token:", error);
Cookies.remove(TOKEN_COOKIE);
throw error;
}
}, []);
const TOKEN_COOKIE = "my_app_token";

function usePrivyAuthProvider() {
const { authenticated, getAccessToken, ready } = usePrivy();

const { reauthorize } = useOAuthTokens({
onOAuthTokenGrant: async ({ oAuthTokens, user }) => {
const token = await getAccessToken();
if (oAuthTokens.provider === "twitter") {
await fetch(
`${import.meta.env.VITE_CONVEX_HTTP_API_URL}/set-twitter-tokens`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
twitterOAuthToken: oAuthTokens.accessToken,
twitterRefreshToken: oAuthTokens.refreshToken,
}),
}
);
}
},
});

// Convert these to refs since they're just used for internal tracking
const isRefreshingRef = React.useRef(false);
const lastPrivyTokenRef = React.useRef<string | null>(null);

const fetchAndStoreToken = useCallback(async (privyToken: string) => {
try {
const response = await fetch(
`${import.meta.env.VITE_CONVEX_HTTP_API_URL}/token-conversion`,
{
method: "POST",
headers: {
Authorization: `Bearer ${privyToken}`,
},
}
);
const data = (await response.json()) as { token: string };

Cookies.set(TOKEN_COOKIE, data.token, {
expires: new Date(Date.now() + 55 * 60 * 1000),
secure: true,
sameSite: "strict",
});

return data.token;
} catch (error) {
console.error("Error fetching token:", error);
Cookies.remove(TOKEN_COOKIE);
throw error;
}
}, []);
cont.
const fetchAccessToken = useCallback(
async ({ forceRefreshToken }: { forceRefreshToken: boolean }) => {
try {
const privyToken = await getAccessToken();

// If the user is not authenticated, remove the token
// this is to prevent the backend from running with an invalid
// privy token
if (!authenticated) {
Cookies.remove(TOKEN_COOKIE);
return null;
}

// Use .current for ref access
const cachedToken = Cookies.get(TOKEN_COOKIE);

if (
!forceRefreshToken &&
cachedToken &&
privyToken &&
(privyToken === lastPrivyTokenRef.current ||
!lastPrivyTokenRef.current)
) {
return cachedToken;
}

if (!privyToken) {
throw new Error("Failed to get Privy token");
}

lastPrivyTokenRef.current = privyToken;

const newToken = await fetchAndStoreToken(privyToken);
return newToken;
} catch (error) {
console.error("Error in fetchAccessToken:", error);
throw error;
}
},
[authenticated, getAccessToken, fetchAndStoreToken]
);

return useMemo(() => {
return {
isLoading: !ready,
isAuthenticated: authenticated,
fetchAccessToken,
};
}, [ready, authenticated, fetchAccessToken]);
}
const fetchAccessToken = useCallback(
async ({ forceRefreshToken }: { forceRefreshToken: boolean }) => {
try {
const privyToken = await getAccessToken();

// If the user is not authenticated, remove the token
// this is to prevent the backend from running with an invalid
// privy token
if (!authenticated) {
Cookies.remove(TOKEN_COOKIE);
return null;
}

// Use .current for ref access
const cachedToken = Cookies.get(TOKEN_COOKIE);

if (
!forceRefreshToken &&
cachedToken &&
privyToken &&
(privyToken === lastPrivyTokenRef.current ||
!lastPrivyTokenRef.current)
) {
return cachedToken;
}

if (!privyToken) {
throw new Error("Failed to get Privy token");
}

lastPrivyTokenRef.current = privyToken;

const newToken = await fetchAndStoreToken(privyToken);
return newToken;
} catch (error) {
console.error("Error in fetchAccessToken:", error);
throw error;
}
},
[authenticated, getAccessToken, fetchAndStoreToken]
);

return useMemo(() => {
return {
isLoading: !ready,
isAuthenticated: authenticated,
fetchAccessToken,
};
}, [ready, authenticated, fetchAccessToken]);
}
If you come up with a nicer pattern than this lmk 🙏
albert chen
albert chen2mo ago
amazing thank you
stardust
stardust2mo ago
where do you get this CONVEX_HTTP_API_URL
timmm
timmmOP2mo ago
It’s ok the dashboard. It’s the convex actions url. I think I added an env variable for it
stardust
stardust2mo ago
i want to use supabase auth, but i dont know if i need same setup Post full code, this will help lots of folks
albert chen
albert chen2mo ago
I ended up implementing it as a cloudflare worker - more decoupled and less self-referential: https://github.com/mralbertchen/privy-oidc-wrapper
GitHub
GitHub - mralbertchen/privy-oidc-wrapper: A service deployed as a C...
A service deployed as a Cloudflare Worker that verifies a Privy Identity Token and re-issues it with three main endpoints: - mralbertchen/privy-oidc-wrapper

Did you find this page helpful?