Skip to content

Instantly share code, notes, and snippets.

@magidandrew
Created July 26, 2024 16:39
Show Gist options
  • Save magidandrew/d7639ff5029dd21198fa9c70c3be1aaa to your computer and use it in GitHub Desktop.
Save magidandrew/d7639ff5029dd21198fa9c70c3be1aaa to your computer and use it in GitHub Desktop.
// Next Imports
import { NextApiRequest, NextApiResponse } from "next";
// Next Auth Imports
import { getCsrfToken } from "next-auth/react";
import CredentialsProvider from "next-auth/providers/credentials";
import TwitterProvider from "next-auth/providers/twitter";
import GitHubProvider from "next-auth/providers/github";
import DiscordProvider from "next-auth/providers/discord";
import { type JWT } from "next-auth/jwt";
import type { AuthOptions, User } from "next-auth";
// Third Party Imports
import jwt from "jsonwebtoken";
import { SiweMessage } from "siwe";
import { ethers } from "ethers";
// Utils Imports
import {
findParamsFromCallbackUrl,
handleUserAuthentication,
insertSocial,
insertWallet,
querySocialByProviderId,
queryUserByPk,
queryUserByWallet,
registerUserWithSocial,
registerUserWithWallet,
verifySignature,
} from "./utils";
import { config } from "@/lib/utils";
import { SigninMessage } from "@/lib/solana/SigninMessage";
import { BLOCKCHAINS, CHAIN_ID_LOOKUP } from "@/lib/constants";
const SESSION_DURATION = 1 * 24 * 60 * 60;
type CombineRequest = Request & NextApiRequest;
type CombineResponse = Response & NextApiResponse;
export const getAuthOptions = (req?: CombineRequest, res?: CombineResponse) =>
({
providers: [
TwitterProvider({
clientId: process.env.TWITTER_API_KEY!,
clientSecret: process.env.TWITTER_API_KEY_SECRET!,
}),
GitHubProvider({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!,
}),
DiscordProvider({
clientId: process.env.DISCORD_CLIENT_ID!,
clientSecret: process.env.DISCORD_CLIENT_SECRET!,
}),
CredentialsProvider({
id: "ethereum",
name: "Ethereum",
credentials: {
message: {
label: "Message",
type: "text",
placeholder: "0x0",
},
signature: {
label: "Signature",
type: "text",
placeholder: "0x0",
},
connector: {
label: "Connector",
type: "text",
},
userId: {
label: "User Id",
type: "text",
},
chainId: {
label: "Chain Id",
type: "text",
},
},
async authorize(credentials, req) {
try {
// Parse the SIWE message from the credentials
const siwe = new SiweMessage(
JSON.parse(credentials?.message || "{}"),
);
const nextAuthUrl = new URL(process.env.NEXTAUTH_URL as string);
const signature = credentials?.signature || "";
const domain = nextAuthUrl.host;
const nonce = await getCsrfToken({ req: { headers: req.headers } });
const chain =
CHAIN_ID_LOOKUP[parseInt(credentials?.chainId || "1")];
const provider = new ethers.JsonRpcProvider(
`${chain.alchemyUrl}/${process.env.ALCHEMY_API_KEY}`,
);
// Verify the signature
let isSuccess = await verifySignature({
siwe,
signature,
domain,
nonce: nonce || "",
provider,
});
if (isSuccess) {
return await handleUserAuthentication(credentials, siwe);
}
return null;
} catch (error: any) {
console.error("Error while logging in with Ethereum:", error);
throw new Error(JSON.stringify({ errors: error.message }));
}
},
}),
CredentialsProvider({
id: "solana",
name: "Solana",
credentials: {
message: {
label: "Message",
type: "text",
},
signature: {
label: "Signature",
type: "text",
},
connector: {
label: "Connector",
type: "text",
},
userId: {
label: "User Id",
type: "text",
},
},
async authorize(credentials, req) {
try {
const signinMessage = new SigninMessage(
JSON.parse(credentials?.message || "{}"),
);
const nextAuthUrl = new URL(process.env.NEXTAUTH_URL!);
if (signinMessage.domain !== nextAuthUrl.host) {
return null;
}
const csrfToken = await getCsrfToken({
req: { ...req, body: null },
});
if (signinMessage.nonce !== csrfToken) {
return null;
}
const validationResult = await signinMessage.validate(
credentials?.signature || "",
);
if (!validationResult)
throw new Error("Could not validate the signed message");
if (validationResult) {
if (
credentials?.userId !== undefined &&
credentials?.userId !== "undefined"
) {
// if the userId exist, its because the user is already logged in,
// we try to link his wallet
await insertWallet({
walletAddress: signinMessage.publicKey,
connector: credentials?.connector || "unknown",
userId: credentials?.userId,
chainId: BLOCKCHAINS.solana.chainId,
isPrimary: false,
});
return {
id: credentials?.userId,
};
} else {
// try to fetch the userByWallet
const user = await queryUserByWallet({
address: signinMessage.publicKey,
});
// if we find one, it means the user is trying to login with his wallet
if (user.length > 0) {
return {
id: user.id,
};
}
// if we don't find a user, it means its a new user and we should register him
const userId = await registerUserWithWallet({
walletAddress: signinMessage.publicKey,
connector: credentials?.connector || "unknown",
chainId: BLOCKCHAINS.solana.chainId,
});
return {
id: userId,
};
}
}
return null;
} catch (error: any) {
console.error({ error });
throw new Error(JSON.stringify({ errors: error.message }));
}
},
}),
],
session: {
strategy: "jwt",
maxAge: 30 * 24 * 60 * 60, // 30 days
updateAge: 24 * 60 * 60, // 24 hours
},
callbacks: {
async jwt({ token, user }: { token: JWT; user: User }) {
if (user) {
token.userId = user.id;
}
if (token.userId) {
const queryUserRes = await queryUserByPk({ userId: token.userId });
token.userId = queryUserRes.id;
}
return token;
},
async session({ session, token }) {
if (token.userId && session.user) {
session.user.id = token.userId as string;
}
const encodedToken = jwt.sign(token, process.env.NEXTAUTH_SECRET!, {
algorithm: "HS256",
});
session.token = encodedToken;
return session;
},
async signIn({ user, account }) {
if (account?.type === "oauth") {
const userId = findParamsFromCallbackUrl(req);
// if the user is not logged in, we try to find the associated account (search in the account using the providerAccountId)
if (!userId && account?.providerAccountId) {
const social = await querySocialByProviderId({
providerAccountId: account?.providerAccountId,
});
// if we find an associated account, we proceed to login the user
if (social) {
user.id = social.user_id;
} else {
// if we don't find any associated account, we create a user and link this social account to it
const userId = await registerUserWithSocial({
provider: account.provider,
providerAccountId: account.providerAccountId,
accessToken: (account.access_token ||
account.oauth_token) as string,
expiresAt:
account.expires_at ||
Math.floor(Date.now() / 1000) + SESSION_DURATION,
username: user.name as string,
image: user.image as string,
});
user.id = userId;
}
} else if (userId) {
// if the user is already LOGGED IN, we try to associated the social account with his user profile.
try {
await insertSocial({
userId: userId as string,
provider: account?.provider as string,
providerAccountId: account?.providerAccountId as string,
accessToken: (account?.access_token ||
account?.oauth_token) as string,
expiresAt:
account?.expires_at ||
Math.floor(Date.now() / 1000) + SESSION_DURATION,
username: user.name as string,
image: user.image as string,
});
user.id = userId as string;
} catch (error: any) {
return `/?success=false&error=${error.message}&type=social&provider=${account?.provider}`;
}
} else {
return false;
}
}
return true;
},
},
jwt: {
secret: config.nextAuth.secret,
encode: async ({ secret, token }): Promise<string> => {
const jwtClaims = {
userId: token?.userId,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + SESSION_DURATION,
"https://hasura.io/jwt/claims": {
"x-hasura-allowed-roles": ["user", "admin"],
"x-hasura-default-role": "user",
"x-hasura-user-id": token?.userId,
},
};
const encodedToken = jwt.sign(jwtClaims, secret, {
algorithm: "HS256",
});
return encodedToken;
},
decode: async ({ secret, token }): Promise<JWT | null> => {
const decodedToken = jwt.verify(token as string, secret, {
algorithms: ["HS256"],
}) as jwt.JwtPayload;
return {
userId: decodedToken.userId,
iat: decodedToken.iat,
exp: decodedToken.exp,
"https://hasura.io/jwt/claims":
decodedToken["https://hasura.io/jwt/claims"],
};
},
},
}) as AuthOptions;
import NextAuth from "next-auth";
import { NextApiRequest, NextApiResponse } from "next";
import { getAuthOptions } from "./getAuthOptions";
// https://next-auth.js.org/configuration/options
type CombineRequest = Request & NextApiRequest;
type CombineResponse = Response & NextApiResponse;
const handler = async (req: CombineRequest, res: CombineResponse) => {
return await NextAuth(req, res, getAuthOptions(req, res));
};
export { handler as GET, handler as POST };
import axios from "axios";
import { config } from "@/lib/utils";
import { getCookies } from "cookies-next";
import { NextApiRequest } from "next";
import { Provider } from "ethers";
import { checkContractWalletSignature, SiweMessage } from "siwe";
import { User } from "next-auth";
type CombineRequest = Request & NextApiRequest;
type Credentials =
| Record<"message" | "signature" | "connector" | "userId" | "chainId", string>
| undefined;
interface VerifySignatureParams {
siwe: SiweMessage;
signature: string;
domain: string;
nonce: string;
provider: Provider;
}
type HasuraRequestParams = {
query?: string;
variables?: Record<string, any>;
};
export async function registerUserWithWallet({
walletAddress,
connector,
chainId,
}: {
walletAddress: string;
connector: string;
chainId: number;
}) {
const userId = await insertUser();
await insertWallet({
walletAddress,
connector,
userId,
chainId,
isPrimary: true,
});
return userId;
}
export async function registerUserWithSocial({
provider,
providerAccountId,
accessToken,
expiresAt,
username,
image,
}: {
provider: string;
providerAccountId: string;
accessToken: string;
expiresAt: number;
username: string;
image: string;
}) {
const userId = await insertUser();
await insertSocial({
userId,
provider,
providerAccountId,
accessToken,
expiresAt,
username,
image,
});
return userId;
}
export async function insertUser() {
const insertUserMutation = `
mutation insertUserMutation($user: users_insert_input!) {
insert_users_one(object: $user) {
id
}
}
`;
const res = await hasuraRequest({
query: insertUserMutation,
variables: {
user: {},
},
});
return res.insert_users_one.id;
}
export async function queryUserByPk({ userId }: { userId: string }) {
const queryString = `
query queryUserByPk($userId: uuid!) {
users_by_pk(id: $userId) {
id
wallets(where: {is_primary: {_eq: true}}) {
id
address
is_primary
connector
}
}
}
`;
const res = await hasuraRequest({
query: queryString,
variables: {
userId,
},
});
return res.users_by_pk;
}
export async function queryUserByWallet({ address }: { address: string }) {
const queryString = `
query queryUserByWallet($address: String!) {
users(where: {wallets: {address: {_eq: $address}}}) {
id
}
}
`;
const res = await hasuraRequest({
query: queryString,
variables: {
address,
},
});
return res.users;
}
export async function querySocialByProviderId({
providerAccountId,
}: {
providerAccountId: string;
}) {
const queryString = `
query querySocialByProviderId($providerAccountId: String!) {
socials(where: {provider_account_id: {_eq: $providerAccountId}}) {
id
user_id
}
}
`;
const res = await hasuraRequest({
query: queryString,
variables: {
providerAccountId,
},
});
return res.socials[0];
}
export async function insertWallet({
walletAddress,
connector,
userId,
chainId,
isPrimary,
}: {
walletAddress: string;
connector: string;
userId: string;
chainId: number;
isPrimary: boolean;
}) {
// INSERT WALLET
const insertWalletMutation = `
mutation insertWalletMutation($wallet: wallets_insert_input!) {
insert_wallets_one(object: $wallet) {
id
}
}
`;
const res = await hasuraRequest({
query: insertWalletMutation,
variables: {
wallet: {
user_id: userId,
address: walletAddress,
connector,
is_primary: isPrimary,
chain_id: chainId,
},
},
});
return res.insert_wallets_one.id;
}
export async function insertSocial({
userId,
provider,
providerAccountId,
accessToken,
expiresAt,
username,
image,
}: {
userId: string;
provider: string;
providerAccountId: string;
accessToken: string;
expiresAt: number;
username: string;
image: string;
}) {
// INSERT WALLET
const insertSocialMutation = `
mutation insertSocialMutation($social: socials_insert_input!) {
insert_socials_one(object: $social) {
id
}
}
`;
const res = await hasuraRequest({
query: insertSocialMutation,
variables: {
social: {
user_id: userId,
provider,
provider_account_id: providerAccountId,
access_token: accessToken,
expires_at: expiresAt,
username,
image,
},
},
});
return res.insert_socials_one.id;
}
export async function hasuraRequest({
query = "",
variables = {},
}: HasuraRequestParams): Promise<any> {
const result = await axios.request({
...config.gqlConfig.options,
data: {
query: query,
variables: variables,
},
});
if (result.data.errors) {
throw new Error(result.data.errors[0].message);
}
return result.data.data;
}
export function findParamsFromCallbackUrl(
req: CombineRequest | undefined,
): string | undefined {
if (!req) {
return undefined;
}
const cookies = getCookies({ req });
const callbackUrl =
cookies["next-auth.callback-url"] ||
cookies["__Secure-next-auth.callback-url"];
if (!callbackUrl) {
return undefined;
}
const url = new URL(callbackUrl);
const urlParams = new URLSearchParams(url.search);
const userId = urlParams.get("userId") || undefined;
return userId === "undefined" ? undefined : userId;
}
export async function verifySignature({
siwe,
signature,
domain,
nonce,
provider,
}: VerifySignatureParams): Promise<boolean> {
try {
// Try to verify the signature using the standard method (suitable for standard wallets like MetaMask)
const result = await siwe.verify({ signature, domain, nonce });
if (result.success) {
return true;
}
} catch (error) {
console.error(
"Error during standard verification, will now try EIP-1271 verification:",
error,
);
}
// If the standard verification fails, proceed with EIP-1271 verification (suitable for Gnosis Safe wallets)
return await checkContractWalletSignature(siwe, signature, provider);
}
export async function handleUserAuthentication(
credentials: Credentials,
siwe: SiweMessage,
): Promise<User> {
const userId = credentials?.userId;
const connector = credentials?.connector || "unknown";
const chainId = parseInt(credentials?.chainId || "0");
if (userId && userId !== "undefined") {
// Link wallet to existing user
await insertWallet({
walletAddress: siwe.address,
connector,
userId,
chainId: chainId,
isPrimary: false,
});
return { id: userId };
}
// Fetch user by wallet address
const users = await queryUserByWallet({ address: siwe.address });
if (users.length > 0) {
// User found, returning user ID
return { id: users[0].id };
}
// Register new user
const newUserId = await registerUserWithWallet({
walletAddress: siwe.address,
connector,
chainId: chainId,
});
return { id: newUserId };
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment