-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- 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
Showing
19 changed files
with
1,476 additions
and
72 deletions.
There are no files selected for viewing
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
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
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', | ||
}, | ||
} |
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
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. |
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
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
85
packages/app-tos/src/auth/gnosis/get-gnosis-safe-details.ts
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
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 } | ||
} |
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
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 | ||
} | ||
} |
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
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, | ||
}) | ||
} |
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
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 | ||
} | ||
} |
Oops, something went wrong.