Skip to content

Commit

Permalink
SIWW auth (#724)
Browse files Browse the repository at this point in the history
This PR adds Sign-In With Wallet auth to the Acre dapp. When a user
connects the wallet we create a session by sending a request to the Acre
API backend. The session is valid for 3 hours.

### SIWW flow:
1. Dapp asks backend for the session.
2. If the session exists and it matches the current connected address, a
user is logged in.
3. If a session exists but does not match the current connected address
the dapp deletes the session and asks backend for a new nonce (session
id).
4. The user must sign the SIWW message with a given nonce.
5. Dapp sends the signature, message, and public key to the backend to
verify the signature.
6. If the message is valid, the backend returns the session id in
cookies and the user is logged in.
  • Loading branch information
kkosiorowska authored Sep 18, 2024
2 parents 23a04a3 + 1d17136 commit bfe8ab0
Show file tree
Hide file tree
Showing 11 changed files with 140 additions and 22 deletions.
1 change: 1 addition & 0 deletions dapp/.env
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ VITE_REFERRAL=0

# TODO: Set this env variable in CI.
VITE_TBTC_API_ENDPOINT=""
VITE_ACRE_API_ENDPOINT="http://localhost:8788/api/v1/"

# API KEYS
VITE_GELATO_RELAY_API_KEY="htaJCy_XHj8WsE3w53WBMurfySDtjLP_TrNPPa6IPIc_" # this key should not be used on production
Expand Down
27 changes: 13 additions & 14 deletions dapp/src/components/ConnectWalletModal/ConnectWalletButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { CONNECTION_ERRORS, ONE_SEC_IN_MILLISECONDS } from "#/constants"
import {
useAppDispatch,
useModal,
useSignMessageAndCreateSession,
useWallet,
useWalletConnectionError,
} from "#/hooks"
Expand All @@ -20,7 +21,6 @@ import {
ImageProps,
VStack,
} from "@chakra-ui/react"
import { useSignMessage } from "wagmi"
import { IconArrowNarrowRight } from "@tabler/icons-react"
import { AnimatePresence, Variants, motion } from "framer-motion"
import ArrivingSoonTooltip from "../ArrivingSoonTooltip"
Expand Down Expand Up @@ -61,7 +61,8 @@ export default function ConnectWalletButton({
onDisconnect,
status: connectionStatus,
} = useWallet()
const { signMessageAsync, status: signMessageStatus } = useSignMessage()
const { signMessageStatus, signMessageAndCreateSession } =
useSignMessageAndCreateSession()
const { closeModal } = useModal()
const dispatch = useAppDispatch()

Expand All @@ -82,22 +83,18 @@ export default function ConnectWalletButton({
}
}, [closeModal, dispatch, onSuccess])

const handleSignMessage = useCallback(
const handleSignMessageAndCreateSession = useCallback(
async (connectedConnector: OrangeKitConnector, btcAddress: string) => {
const message = orangeKit.createSignInWithWalletMessage(btcAddress)
const signedMessage = await signMessageAsync({
message,
connector: orangeKit.typeConversionToConnector(connectedConnector),
})

try {
await orangeKit.verifySignInWithWalletMessage(message, signedMessage)
await signMessageAndCreateSession(connectedConnector, btcAddress)

onSuccessSignMessage()
} catch (error) {
console.error("Failed to sign siww message", error)
setConnectionError(CONNECTION_ERRORS.INVALID_SIWW_SIGNATURE)
}
},
[signMessageAsync, onSuccessSignMessage, setConnectionError],
[signMessageAndCreateSession, onSuccessSignMessage, setConnectionError],
)

const onSuccessConnection = useCallback(
Expand All @@ -106,9 +103,9 @@ export default function ConnectWalletButton({

if (!btcAddress) return

await handleSignMessage(connector, btcAddress)
await handleSignMessageAndCreateSession(connector, btcAddress)
},
[connector, handleSignMessage],
[connector, handleSignMessageAndCreateSession],
)

const handleConnection = useCallback(() => {
Expand Down Expand Up @@ -241,7 +238,9 @@ export default function ConnectWalletButton({
size="lg"
variant="outline"
onClick={() =>
logPromiseFailure(handleSignMessage(connector, address))
logPromiseFailure(
handleSignMessageAndCreateSession(connector, address),
)
}
>
Resume and try again
Expand Down
3 changes: 3 additions & 0 deletions dapp/src/constants/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ const NETWORK_TYPE = USE_TESTNET ? "testnet" : "mainnet"

const LATEST_COMMIT_HASH = import.meta.env.VITE_LATEST_COMMIT_HASH

const ACRE_API_ENDPOINT = import.meta.env.VITE_ACRE_API_ENDPOINT

export default {
PROD,
USE_TESTNET,
Expand All @@ -35,4 +37,5 @@ export default {
MEZO_PORTAL_API_KEY,
NETWORK_TYPE,
LATEST_COMMIT_HASH,
ACRE_API_ENDPOINT,
}
4 changes: 4 additions & 0 deletions dapp/src/constants/time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,7 @@ export const REFETCH_INTERVAL_IN_MILLISECONDS =
ONE_SEC_IN_MILLISECONDS * ONE_MINUTE_IN_SECONDS * 5

export const DATE_FORMAT_LOCALE_TAG = "us-US"

// 7 days
export const ACRE_SESSION_EXPIRATION_TIME =
ONE_WEEK_IN_SECONDS * ONE_SEC_IN_MILLISECONDS
1 change: 1 addition & 0 deletions dapp/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ export { default as useLocalStorage } from "./useLocalStorage"
export { default as useDetectReferral } from "./useDetectReferral"
export { default as useReferral } from "./useReferral"
export { default as useMats } from "./useMats"
export { default as useSignMessageAndCreateSession } from "./useSignMessageAndCreateSession"
57 changes: 57 additions & 0 deletions dapp/src/hooks/useSignMessageAndCreateSession.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { OrangeKitConnector } from "#/types"
import { acreApi, orangeKit } from "#/utils"
import { useCallback } from "react"
import { useSignMessage } from "wagmi"

function useSignMessageAndCreateSession() {
const { signMessageAsync, status: signMessageStatus } = useSignMessage()

const signMessageAndCreateSession = useCallback(
async (connectedConnector: OrangeKitConnector, btcAddress: string) => {
let session = await acreApi.getSession()
const hasSessionAddress = "address" in session

const isSessionAddressEqual = hasSessionAddress
? (session as { address: string }).address === btcAddress
: false

if (hasSessionAddress && isSessionAddressEqual) {
return
}

if (hasSessionAddress && !isSessionAddressEqual) {
// Delete session.
await acreApi.deleteSession()
// Ask for nonce to create new session.
session = await acreApi.getSession()
}

if (!("nonce" in session)) {
throw new Error("Session nonce not available")
}

const message = orangeKit.createSignInWithWalletMessage(
btcAddress,
session.nonce,
)

const signedMessage = await signMessageAsync({
message,
connector: orangeKit.typeConversionToConnector(connectedConnector),
})

const publicKey = await connectedConnector
.getBitcoinProvider()
.getPublicKey()

await acreApi.createSession(message, signedMessage, publicKey)
},
[signMessageAsync],
)

return {
signMessageAndCreateSession,
signMessageStatus,
}
}
export default useSignMessageAndCreateSession
41 changes: 41 additions & 0 deletions dapp/src/utils/acre-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { env } from "#/constants"
import axiosStatic from "axios"

const axios = axiosStatic.create({
baseURL: env.ACRE_API_ENDPOINT,
withCredentials: true,
})

async function getSession() {
const response = await axios.get<{ nonce: string } | { address: string }>(
"session",
)

return response.data
}

async function createSession(
message: string,
signature: string,
publicKey: string,
) {
const response = await axios.post<{ success: boolean }>("session", {
message,
signature,
publicKey,
})

if (!response.data.success) {
throw new Error("Failed to create Acre session")
}
}

async function deleteSession() {
await axios.delete("session")
}

export default {
createSession,
getSession,
deleteSession,
}
1 change: 1 addition & 0 deletions dapp/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ export { default as userAgent } from "./userAgent"
export { default as referralProgram } from "./referralProgram"
export { default as mezoPortalAPI } from "./mezoPortalApi"
export { default as router } from "./router"
export { default as acreApi } from "./acre-api"
13 changes: 11 additions & 2 deletions dapp/src/utils/orangeKit.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { CONNECTION_ERRORS, wallets } from "#/constants"
import {
ACRE_SESSION_EXPIRATION_TIME,
CONNECTION_ERRORS,
wallets,
} from "#/constants"
import {
ConnectionErrorData,
OrangeKitError,
Expand All @@ -10,6 +14,7 @@ import {
} from "@orangekit/react"
import { Connector } from "wagmi"
import { SignInWithWalletMessage } from "@orangekit/sign-in-with-wallet"
import { getExpirationDate } from "./time"

const getWalletInfo = (connector: OrangeKitConnector) => {
switch (connector.id) {
Expand Down Expand Up @@ -37,7 +42,7 @@ const isConnectedStatus = (status: string) => status === "connected"
const isOrangeKitConnector = (connector?: Connector) =>
connector?.type === "orangekit"

const createSignInWithWalletMessage = (address: string) => {
const createSignInWithWalletMessage = (address: string, nonce: string) => {
const { host: domain, origin: uri } = window.location

const message = new SignInWithWalletMessage({
Expand All @@ -47,6 +52,10 @@ const createSignInWithWalletMessage = (address: string) => {
issuedAt: new Date().toISOString(),
version: "1",
networkFamily: "bitcoin",
expirationTime: getExpirationDate(
ACRE_SESSION_EXPIRATION_TIME,
).toISOString(),
nonce,
})

return message.prepareMessage()
Expand Down
13 changes: 7 additions & 6 deletions dapp/src/utils/time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,14 @@ export const displayBlockTimestamp = (blockTimestamp: number) => {
return getRelativeTime(blockTimestamp)
}

export const getExpirationDate = (duration: number, startDate?: Date) => {
const date = startDate ?? new Date()
return new Date(date.getTime() + duration)
}

/**
* Returns the expiration timestamp from the start date considering the specified duration.
* If the startDate is not passed, the function will take the current time as the start date.
*/
export const getExpirationTimestamp = (duration: number, startDate?: Date) => {
const date = startDate ?? new Date()
const expirationDate = new Date(date.getTime() + duration)

return dateToUnixTimestamp(expirationDate)
}
export const getExpirationTimestamp = (duration: number, startDate?: Date) =>
dateToUnixTimestamp(getExpirationDate(duration, startDate))
1 change: 1 addition & 0 deletions dapp/src/vite-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ interface ImportMetaEnv {
readonly VITE_SUBGRAPH_API_KEY: string
readonly VITE_MEZO_PORTAL_API_KEY: string
readonly VITE_LATEST_COMMIT_HASH: string
readonly VITE_ACRE_API_ENDPOINT: string
}

interface ImportMeta {
Expand Down

0 comments on commit bfe8ab0

Please sign in to comment.