Skip to content

Commit

Permalink
TOS package (#456)
Browse files Browse the repository at this point in the history
- added TOS package (without server handling)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit


- **New Features**
	- Introduced a hook for managing Terms of Service (ToS) acceptance.
- Added functions for signing JWT, verifying ToS acceptance, and
handling user authentication with Gnosis Safe.
- Implemented mechanisms for managing error handling and retry logic for
asynchronous operations.
- Enhanced flexibility for local development in RPC endpoint generation.

- **Documentation**
- Created a README file detailing the functionalities of the Terms of
Service package.

- **Chores**
- Established a new ESLint configuration and TypeScript configuration
for the project.
	- Added a Vite configuration for improved build processes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
  • Loading branch information
piekczyk authored Jul 30, 2024
1 parent 2b4ecee commit 97521c8
Show file tree
Hide file tree
Showing 19 changed files with 1,476 additions and 72 deletions.
5 changes: 4 additions & 1 deletion apps/rays-dashboard/constants/networks-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,10 @@ function getRpc(network: NetworkNames): string {
return ''
}

return `${window.location.origin}/api/rpcGateway?network=${network}&clientId=${clientId}`
// for local testing
const resolvedOrigin = window.location.origin.replace('3001', '3000')

return `${resolvedOrigin}/api/rpcGateway?network=${network}&clientId=${clientId}`
}

export const mainnetRpc = getRpc(NetworkNames.ethereumMainnet)
Expand Down
16 changes: 16 additions & 0 deletions packages/app-tos/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
root: true,
extends: ['@summerfi/eslint-config/next.cjs'],
parser: '@typescript-eslint/parser',
parserOptions: {
project: ['./tsconfig.json'],
sourceType: 'module',
tsconfigRootDir: __dirname,
},
ignorePatterns: ['types', 'dist', 'node_modules'],
rules: {
'@next/next/no-img-element': 'off',
'no-magic-numbers': 'off',
},
}
10 changes: 10 additions & 0 deletions packages/app-tos/README.MD
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# @summerfi/app-tos

Common Terms of Service (TOS) handling for the @summerfi apps.

This package provides `useTermsOfService` hook and types which can be used to easily integrate TOS
flow in new apps.

This package do not contain any UI which should be handled / customized on the client app side.

This package assumes that API already exists, which in the end may vary per specific app.
44 changes: 44 additions & 0 deletions packages/app-tos/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"name": "@summerfi/app-tos",
"version": "0.0.0",
"license": "Apache-2.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/types/src/index.d.ts",
"files": [
"dist"
],
"scripts": {
"lint": "eslint *.ts*",
"dev": "pnpm vite build -w true",
"build": "pnpm vite build -w false",
"clean": "rm -rf dist"
},
"devDependencies": {
"@summerfi/app-types": "workspace:*",
"@summerfi/eslint-config": "workspace:*",
"@summerfi/typescript-config": "workspace:*",
"@types/react": "^18.3.1",
"@types/jsonwebtoken": "^9.0.0",
"@vitejs/plugin-react": "^4.2.1",
"vite-plugin-node-polyfills": "^0.22.0",
"eslint": "8.57.0",
"typescript": "^5.4.5",
"vite": "^5.2.11",
"vite-plugin-dts": "^3.9.1",
"glob": "^10.3.12"
},
"peerDependencies": {
"react": "^18.3.1",
"usehooks-ts": "^3.1.0",
"@safe-global/safe-apps-sdk": "^9.0.0",
"jsonwebtoken": "^9.0.0"
},
"publishConfig": {
"access": "public"
},
"dependencies": {
"rollup-preserve-directives": "^1.1.1",
"vite-tsconfig-paths": "^4.3.2"
}
}
85 changes: 85 additions & 0 deletions packages/app-tos/src/auth/gnosis/get-gnosis-safe-details.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import type SafeAppsSDK from '@safe-global/safe-apps-sdk'
// eslint-disable-next-line no-duplicate-imports
import { type SignMessageResponse } from '@safe-global/safe-apps-sdk'
import { decode } from 'jsonwebtoken'

import { getDataToSignFromChallenge } from '@/helpers/get-data-to-sign-from-challenge'

const LOCAL_STORAGE_GNOSIS_SAFE_PENDING = 'gnosis-safe-pending'

interface GnosisSafeSignInDetails {
messageHash: string
challenge: string
}

interface GnosisSafeSignInDetailsWithData extends GnosisSafeSignInDetails {
dataToSign: string
}

/**
* Retrieves or generates Gnosis Safe sign-in details including a message hash and data to sign.
*
* @remarks
* This method checks for a pending signature in local storage. If found and not expired,
* it uses the existing data. Otherwise, it generates a new signature request and stores the details.
*
* @param sdk - The SafeAppsSDK instance used to sign messages.
* @param chainId - The chain ID of the blockchain network.
* @param account - The account address for which the details are being retrieved or generated.
* @param newChallenge - A new challenge string used to generate the data to sign if no pending signature is found.
*
* @returns A promise that resolves to an object containing the message hash, challenge, and data to sign.
*
* @throws Will throw an error if the response from the SDK is unexpected or if the message hash is not defined.
*/

export const getGnosisSafeDetails = async (
sdk: SafeAppsSDK,
chainId: number,
account: string,
newChallenge: string,
): Promise<GnosisSafeSignInDetailsWithData> => {
const key = `${LOCAL_STORAGE_GNOSIS_SAFE_PENDING}/${chainId}-${account}`
const localStorageItem = localStorage.getItem(key)
const pendingSignature: GnosisSafeSignInDetails | undefined = localStorageItem
? JSON.parse(localStorageItem)
: undefined

if (pendingSignature) {
const exp = (decode(pendingSignature.challenge) as any)?.exp

if (exp && exp * 1000 >= Date.now()) {
return {
...pendingSignature,
dataToSign: getDataToSignFromChallenge(pendingSignature.challenge),
}
}
}

const dataToSign = getDataToSignFromChallenge(newChallenge)
const res = (await sdk.txs.signMessage(dataToSign)) as SignMessageResponse
let messageHash: string | undefined

if ('messageHash' in res) {
// eslint-disable-next-line prefer-destructuring
messageHash = res.messageHash
} else if ('safeTxHash' in res) {
throw new Error('Please upgrade your SAFE')
} else {
throw new Error('Unexpected response type')
}

if (!messageHash) {
throw new Error('Safe messageHash not defined')
}

localStorage.setItem(
key,
JSON.stringify({
messageHash,
challenge: newChallenge,
} as GnosisSafeSignInDetails),
)

return { challenge: newChallenge, messageHash, dataToSign }
}
39 changes: 39 additions & 0 deletions packages/app-tos/src/auth/request-challenge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Requests a challenge from the authentication server.
*
* @remarks
* This method sends a POST request to the `/api/auth/challenge` endpoint with the provided wallet address and
* Gnosis Safe status. It returns a challenge string on success or undefined if an error occurs.
*
* @param walletAddress - The wallet address to be used in the challenge request.
* @param isGnosisSafe - A boolean indicating if the wallet is a Gnosis Safe.
* @param host - Optional, to be used when API is not available under the same host (for example localhost development on different ports).
*
* @returns A promise that resolves to the challenge string or undefined if an error occurs.
*/
export const requestChallenge = async ({
walletAddress,
isGnosisSafe,
host = '',
}: {
walletAddress: string
isGnosisSafe: boolean
host?: string
}): Promise<string | undefined> => {
try {
const { challenge } = await fetch(`${host}/api/auth/challenge`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ address: walletAddress.toLowerCase(), isGnosisSafe }),
}).then((resp) => resp.json())

return challenge
} catch (e) {
// eslint-disable-next-line no-console
console.error('Request challenge error', e)

return undefined
}
}
110 changes: 110 additions & 0 deletions packages/app-tos/src/auth/request-jwt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
'use client'
import SafeAppsSDK from '@safe-global/safe-apps-sdk'

import { getGnosisSafeDetails } from '@/auth/gnosis/get-gnosis-safe-details'
import { requestChallenge } from '@/auth/request-challenge'
import { requestSignin } from '@/auth/request-signin'
import { signTypedPayload } from '@/helpers/sign-typed-payload'
import { type TOSSignMessage } from '@/types'

/**
* Requests a JSON Web Token (JWT) by signing a challenge.
*
* @remarks
* This method first requests a challenge from the server. If the wallet is a Gnosis Safe, it retrieves Gnosis Safe details and
* polls for an off-chain signature to request a sign-in. Otherwise, it uses the provided signing function to sign the challenge
* and request a sign-in.
*
* @param signMessage - The function used to sign the challenge message.
* @param chainId - The chain ID of the blockchain network.
* @param walletAddress - The wallet address to be used for signing.
* @param isGnosisSafe - A boolean indicating if the wallet is a Gnosis Safe.
* @param host - Optional, to be used when API is not available under the same host (for example localhost development on different ports).
*
* @returns A promise that resolves to the JWT string or undefined if an error occurs.
* @throws Will throw an error if the challenge request fails or if signing with Gnosis Safe fails.
*/
export async function requestJWT({
signMessage,
chainId,
walletAddress,
isGnosisSafe,
host,
}: {
signMessage: TOSSignMessage
chainId: number
walletAddress: string
isGnosisSafe: boolean
host?: string
}): Promise<string | undefined> {
const challenge = await requestChallenge({ walletAddress, isGnosisSafe, host })

if (!challenge) {
throw new Error('Request challenge failed, try again or contact with support')
}

if (isGnosisSafe) {
const sdk = new SafeAppsSDK()

const { challenge: gnosisSafeChallenge, messageHash } = await getGnosisSafeDetails(
sdk,
chainId,
walletAddress,
challenge,
)

// start polling
const token = await new Promise<string | undefined>((resolve) => {
let returnValue = (val: string | undefined) => resolve(val) // CAUTION: this function is reassigned later
const interval = setInterval(async () => {
if (messageHash) {
try {
const offchainSignature = await sdk.safe.getOffChainSignature(messageHash)

if (offchainSignature === '') {
throw new Error('GnosisSafe: not ready')
}
const safeJwt = await requestSignin({
challenge: gnosisSafeChallenge,
signature: offchainSignature,
chainId,
isGnosisSafe,
host,
})

return returnValue(safeJwt)
} catch (error) {
// eslint-disable-next-line no-console
console.error('GnosisSafe: error occurred', error)
}
}
}, 5 * 1000)

// clear all scheduled callbacks
returnValue = (val: string | undefined) => {
clearInterval(interval)
resolve(val)
}
})

if (!token) {
throw new Error(`GnosisSafe: failed to sign`)
}

return token
}

const signature = await signTypedPayload(challenge, signMessage)

if (!signature) {
throw new Error('Signing process declined or failed, try again or contact with support')
}

return await requestSignin({
challenge,
signature,
chainId,
isGnosisSafe: false,
host,
})
}
47 changes: 47 additions & 0 deletions packages/app-tos/src/auth/request-signin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* Requests a JSON Web Token (JWT) by sending a signed challenge to the authentication server.
*
* @remarks
* This method sends a POST request to the `/api/auth/signin` endpoint with the provided signature, challenge,
* chain ID, and Gnosis Safe status. It returns a JWT string on success or undefined if an error occurs.
*
* @param signature - The signature generated by signing the challenge.
* @param challenge - The challenge string to be signed.
* @param chainId - The chain ID of the blockchain network.
* @param isGnosisSafe - A boolean indicating if the wallet is a Gnosis Safe.
* @param host - Optional, to be used when API is not available under the same host (for example localhost development on different ports).
*
* @returns A promise that resolves to the JWT string or undefined if an error occurs.
* @throws Will log an error to the console if the request fails.
*/
export const requestSignin = async ({
signature,
challenge,
chainId,
isGnosisSafe,
host = '',
}: {
signature: string
challenge: string
chainId: number
isGnosisSafe: boolean
host?: string
}): Promise<string | undefined> => {
try {
const { jwt } = await fetch(`${host}/api/auth/signin`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ signature, challenge, chainId, isGnosisSafe }),
credentials: 'include',
}).then((resp) => resp.json())

return jwt
} catch (e) {
// eslint-disable-next-line no-console
console.error('Request challenge error', e)

return undefined
}
}
Loading

0 comments on commit 97521c8

Please sign in to comment.