Create a cryptographic session using Remix

Thoughts

I'm a big fan of the decentralization capabilities that Ethereum brings to the world. Having the option of being transparent vs not being, I think speaks for itself about what's the better choice. How long will it take to get there, it's another topic. We might get there eventually, which would make sense to me. I would leave the speculative part of it all, I do not consider that as important

I'm a big fan of being secured so I probably have 2FA enabled for all SSO around the internet that I consume, along with a password manager, for better security, why not?

There is a different option which makes use of cryptography, a foundational concept of Ethereum. This option is an EIP (Ethereum Improvement Proposal) which basically is a formal proposition of abstraction in the Ethereum ecosystem. When (if) it gets approved, developers start using them. This EIP ships with a Javascript library called SIWE (Sign in with Ethereum) that has the required API to provide cryptographical session validation

Demo

I've created an example repository and also there is a running demo in you'd like to jump right to the code or just see how it works

Hands-on

First, we are going to work on the /join route. On this page, we are going to create a user using the user's connected wallet. With this wallet, the user will be able to sign (cryptographically) a message. Because of how cryptography works, with these values: the signed message (signature) and the message itself, we'll be able to check if the user is in fact how he says he is.

Firstly, we are going to add the code to connect to user's wallet. We'll check the implementation of useProvider next in the article

export default function JoinPage() {
  const { provider, connectMetamask } = useProvider()

  return (
    <main>
      <button
        aria-label="Connect your wallet"
        onClick={() => connectMetamask()}
      >
        <span>1</span>
        <h3>
          Connect your wallet
        </h3>
      </button>
      <button>
        ...
      </button>
      <Form>
        ...
      </Form>
    </main>
  )
}

Secondly, with the wallet connected, we can programmatically ask the user to sign a message. Let's add the second step. We'll check the implementation of getAccount and getSigner next in the article. Same thing with nonce's value

import { SiweMessage } from "siwe"

export default function JoinPage() {
  const { nonce } = useLoaderData<typeof loader>()
  const { provider } = useProvider()
  const [account, setAccount] = useState<string | undefined>(undefined)
  const [message, setMessage] = useState<string | undefined>(undefined)
  const [signature, setSignature] = useState<string | undefined>(undefined)

  return (
    <main>
      <button>
        ...
      </button>
      <button
        aria-label="Generate personal signature"
        onClick={async () => {
          if (!provider) {
            alert("You need to have Metamask connected to create your signature")

            return
          }

          const account = await getAccount(provider)
          const signer = getSigner(provider)

          const siweMessage = new SiweMessage({
            uri: window.location.origin,
            domain: window.location.host,
            nonce,
            address: account,
            version: "0.1",
            chainId: 1,
            statement: "Sign in with Ethereum to this application",
          })

          const message = siweMessage.prepareMessage()
          setMessage(message)
          setSignature(await signer.signMessage(message))
          setAccount(account)
        }}
      >
        <span>2</span>
        <h3>
          Generate personal signature
        </h3>
      </button>
      <Form>
        ...
      </Form>
    </main>
  )
}

Here is the code for the utility functions used to manage user's wallet. This could be easily done with a library like wagmi or useDapp. I prefer not using them for such a simple thing

First, the code for useProvider:

function useProvider(): {
  provider: Web3Provider | undefined
  connectProvider: () => void
} {
  const [provider, setProvider] = useState<Web3Provider | undefined>(undefined)

  async function getProvider() {
    if ((window as any)?.ethereum) {
      const provider = new Web3Provider((window as any).ethereum)
      const account = await getAccount(provider)

      if (!account)
        return setProvider(undefined)

      setProvider(provider)
    }
    else {
      setProvider(undefined)
    }
  }

  useEffect(() => {
    if (typeof window === "undefined")
      return

    getProvider()
  }, [])

  function connectProvider() {
    new Web3Provider((window as any).ethereum)
      .send("eth_requestAccounts", [])
      .then(() => {
        if (provider)
          return

        getProvider()
      })
      .catch((error) => {
        if (error.code === -32002) {
          alert(
            "You have already connected Metamask to the application. Click on the Metamask extension and type your password",
          )
        }
      })
  }

  return { provider, connectProvider  }
}

This is the code for getAccount:

async function getAccount(provider: Web3Provider): Promise<string> {
  return provider.send("eth_accounts", []).then(accounts => accounts[0])
}

and this for getSigner:

function getSigner(provider: Web3Provider): JsonRpcSigner {
  return provider.getSigner()
}

As promised, we also need to know the nonce value. What's the nonce? it's just a random string that SIWE requires to prevent re-entry attacks

import { createCookie } from "@remix-run/node"
import { generateNonce } from "siwe"

const nonce = createCookie("nonce", {
  maxAge: 604_800,
})

export async function loader({ request }: LoaderArgs) {
  const cookieHeader = request.headers.get("Cookie")
  const cookie = (await nonce.parse(cookieHeader)) || {}

  if (!cookie.nonce) {
    const nextNonce = generateNonce()
    cookie.nonce = nextNonce

    return json(
      {
        nonce: nextNonce,
      },
      {
        headers: {
          "Set-Cookie": await nonce.serialize(cookie),
        },
      },
    )
  }

  return json({
    nonce: cookie.nonce,
  })
}

Great, so with this code we create a nonce cookie. When the loader runs, we check if there is already a value set for the cookie, otherwise, we generate one and store it. This cookie will be sent back and forth between the client and the server, which fits perfectly for us

Now we have both message and signature in place. The next step is to send these values to the server to validate them using SIWE library's API. To do this, the user has to click on the Login button. This is the code to do that:

export default function JoinPage() {
  return (
    <main>
      <button>
        ...
      </button>
      <button>
        ...
      </button>
      <Form method="post">
        <input type="hidden" name="message" value={message} />
        <input type="hidden" name="account" value={account} />
        <input type="hidden" name="signature" value={signature} />
        <button
          type="submit"
          name="_action"
          aria-label="Connect your wallet"
          disabled={Boolean(!message) || Boolean(!signature)}
        >
          <span>3</span>
          <h3>Login</h3>
        </button>
      </Form>
    </main>
  )
}

Now let's continue with the flow. We are now receiving message and signature on the action function from Remix, after having clicked on the Generate personal signature button. We need to do some basic form validation just to make sure we can safely work with the values provided. Let's go ahead and do that first:

export async function action({ request }: ActionArgs) {
  const formData = await request.formData()

  const message = formData.get("message")
  const account = formData.get("account")
  const signature = formData.get("signature")

  if (typeof message !== "string") {
    return json(
      {
        errors: {
          nonce: null,
          account: null,
          message: "Message is required",
          signature: null,
        },
      },
      { status: 400 },
    )
  }

  if (typeof account !== "string") {
    return json(
      {
        errors: {
          nonce: null,
          account: "A connected account is required",
          message: null,
          signature: null,
        },
      },
      { status: 400 },
    )
  }

  if (typeof signature !== "string") {
    return json(
      {
        errors: {
          nonce: null,
          account: null,
          message: null,
          signature: "Signature is required",
        },
      },
      { status: 400 },
    )
  }
}

So if our code goes after these validations, it means we have everything we need, the way we need it. Next, we'll do the validation using the SIWE library. The validation can be seen in this line in an example shared by the SIWE team

import { SiweMessage } from "siwe"

export async function action({ request }: ActionArgs) {
  // basic validations come before this point
  try {
    const siweMessage = new SiweMessage(message)
    // next line does the trick
    await siweMessage.validate(signature) // this will throw if it's invalid

    const cookieHeader = request.headers.get("Cookie")
    const cookie = (await nonce.parse(cookieHeader)) || {}

    if (siweMessage.nonce !== cookie.nonce) {
      return json(
        {
          errors: {
            nonce: "Invalid nonce",
            account: null,
            message: null,
            signature: null,
          },
        },
        { status: 422 },
      )
    }
  }
  catch (error) {
    // we are handling the error next
    // ...
  }
}

Great, so if the execution gets past the try-catch block, then it means that the values sent by the user are valid. If we do, then we finish up the user creation flow

import { createUser, getUserByAddress } from "~/models/user.server"
import { createUserSession } from "~/utils/session.server"

export async function action({ request }: ActionArgs) {
  try {
    // ...
  }
  catch (error) {
    // ...
  }

  const user = await getUserByAddress(account)

  if (!user) {
    const nextUser = await createUser(account)

    return createUserSession({
      request,
      userAddress: nextUser.address,
      remember: true,
      redirectTo
    })
  }
  else {
    return createUserSession({
      request,
      userAddress: user.address,
      remember: true,
      redirectTo
    })
  }
}

The createUser comes from any Remix Stack, like the one in Blues Stack. Here is it's implementation. It also shows on Remix's guided tutorial. The same thing goes for createUserSession but with a little tweak because I'm using the address as the user's identifier. You can check it's implementation here

We are missing one more thing: handling expected errors for the SIWE library. Let's add that to our action

import { ErrorTypes } from "siwe"

export async function action({ request }: ActionArgs) {
  try {
    // ...
  }
  catch (error) {
    switch (error) {
      case ErrorTypes.EXPIRED_MESSAGE: {
        return json(
          {
            errors: {
              valid: null,
              signature: null,
              expired: "Your sesion has expired",
              message: null,
              nonce: null,
            },
          },
          { status: 400 },
        )
      }
      case ErrorTypes.INVALID_SIGNATURE: {
        return json(
          {
            errors: {
              valid: "Your signature is invalid",
              signature: null,
              expired: null,
              message: null,
              nonce: null,
            },
          },
          { status: 400 },
        )
      }
      default: {
        break
      }
    }
  }
}

That's it for creating a new user! For the /login route the whole code is the very same, but we don't use createUser, we just create a session and that's it. The code would look like this:

// /routes/login
import { createUser } from "~/models/user.server"
import { createUserSession } from "~/utils/session.server"

export async function action({ request }: ActionArgs) {
  try {
    // ...
  }
  catch (error) {
    // ...
  }
  // note the removal of "createUser"

  return createUserSession({
    request,
    userAddress: user.address,
    remember: true,
  })
}