Created
July 26, 2024 16:39
-
-
Save magidandrew/d7639ff5029dd21198fa9c70c3be1aaa to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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