├── .gitignore ├── .npmignore ├── README.md ├── bun.lockb ├── package.json ├── src ├── SolanaAuthProvider.tsx └── index.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | solana-react-auth-test/ 4 | # Logs 5 | 6 | logs 7 | _.log 8 | npm-debug.log_ 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | .pnpm-debug.log* 13 | 14 | # Caches 15 | 16 | .cache 17 | 18 | # Diagnostic reports (https://nodejs.org/api/report.html) 19 | 20 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 21 | 22 | # Runtime data 23 | 24 | pids 25 | _.pid 26 | _.seed 27 | *.pid.lock 28 | 29 | # Directory for instrumented libs generated by jscoverage/JSCover 30 | 31 | lib-cov 32 | 33 | # Coverage directory used by tools like istanbul 34 | 35 | coverage 36 | *.lcov 37 | 38 | # nyc test coverage 39 | 40 | .nyc_output 41 | 42 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 43 | 44 | .grunt 45 | 46 | # Bower dependency directory (https://bower.io/) 47 | 48 | bower_components 49 | 50 | # node-waf configuration 51 | 52 | .lock-wscript 53 | 54 | # Compiled binary addons (https://nodejs.org/api/addons.html) 55 | 56 | build/Release 57 | 58 | # Dependency directories 59 | 60 | node_modules/ 61 | jspm_packages/ 62 | 63 | # Snowpack dependency directory (https://snowpack.dev/) 64 | 65 | web_modules/ 66 | 67 | # TypeScript cache 68 | 69 | *.tsbuildinfo 70 | 71 | # Optional npm cache directory 72 | 73 | .npm 74 | 75 | # Optional eslint cache 76 | 77 | .eslintcache 78 | 79 | # Optional stylelint cache 80 | 81 | .stylelintcache 82 | 83 | # Microbundle cache 84 | 85 | .rpt2_cache/ 86 | .rts2_cache_cjs/ 87 | .rts2_cache_es/ 88 | .rts2_cache_umd/ 89 | 90 | # Optional REPL history 91 | 92 | .node_repl_history 93 | 94 | # Output of 'npm pack' 95 | 96 | *.tgz 97 | 98 | # Yarn Integrity file 99 | 100 | .yarn-integrity 101 | 102 | # dotenv environment variable files 103 | 104 | .env 105 | .env.development.local 106 | .env.test.local 107 | .env.production.local 108 | .env.local 109 | 110 | # parcel-bundler cache (https://parceljs.org/) 111 | 112 | .parcel-cache 113 | 114 | # Next.js build output 115 | 116 | .next 117 | out 118 | 119 | # Nuxt.js build / generate output 120 | 121 | .nuxt 122 | dist 123 | 124 | # Gatsby files 125 | 126 | # Comment in the public line in if your project uses Gatsby and not Next.js 127 | 128 | # https://nextjs.org/blog/next-9-1#public-directory-support 129 | 130 | # public 131 | 132 | # vuepress build output 133 | 134 | .vuepress/dist 135 | 136 | # vuepress v2.x temp and cache directory 137 | 138 | .temp 139 | 140 | # Docusaurus cache and generated files 141 | 142 | .docusaurus 143 | 144 | # Serverless directories 145 | 146 | .serverless/ 147 | 148 | # FuseBox cache 149 | 150 | .fusebox/ 151 | 152 | # DynamoDB Local files 153 | 154 | .dynamodb/ 155 | 156 | # TernJS port file 157 | 158 | .tern-port 159 | 160 | # Stores VSCode versions used for testing VSCode extensions 161 | 162 | .vscode-test 163 | 164 | # yarn v2 165 | 166 | .yarn/cache 167 | .yarn/unplugged 168 | .yarn/build-state.yml 169 | .yarn/install-state.gz 170 | .pnp.* 171 | 172 | # IntelliJ based IDEs 173 | .idea 174 | 175 | # Finder (MacOS) folder config 176 | .DS_Store 177 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | solana-react-auth-test/ 2 | # Ignore source code if you're publishing only the build output 3 | src/ 4 | # Ignore Bun's lockfile 5 | bun.lockb 6 | 7 | # Ignore TypeScript configs and cache 8 | tsconfig.json 9 | 10 | # Ignore build tools and scripts 11 | scripts/ 12 | .vscode/ 13 | .env 14 | .DS_Store # macOS specific 15 | Thumbs.db # Windows specific 16 | 17 | # Ignore test files and coverage 18 | tests/ 19 | test/ 20 | *.test.* 21 | coverage/ 22 | .jest/ 23 | .nyc_output/ 24 | 25 | # Ignore Git files and metadata 26 | .git/ 27 | .gitignore 28 | 29 | # Ignore unnecessary files 30 | npm-debug.log 31 | yarn.lock 32 | package-lock.json 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Solana React Auth Provider 2 | 3 | A React context provider that allows users to authenticate using their Solana wallet. It uses browser local storage to store the authentication data. which includes the signature, public key, and the timestamp of the signature in seconds. 4 | It verifies the signature using the public key and the signature itself and also checks the timestamp to ensure that the signature is not expired. 5 | 6 | # Installation 7 | 8 | ### bun 9 | 10 | ```bash 11 | bun add solana-react-auth 12 | ``` 13 | 14 | ### yarn 15 | 16 | ```bash 17 | yarn add solana-react-auth 18 | ``` 19 | 20 | ### npm 21 | 22 | ```bash 23 | npm install solana-react-auth 24 | ``` 25 | 26 | # Exposed Objects 27 | 28 | ### `SolanaAuthProvider` 29 | 30 | A React component that wraps the application and provides the authentication context. It takes the following props: 31 | 32 | - **`wallet`**: WalletContext from useWallet hook 33 | - **`message`**: A string or object that represents the message to be signed by the user. 34 | - **`authTimeout`**: A number that represents the timeout of the authentication in seconds. 35 | 36 | > Note: This provider should be a child of WalletProvider from @solana/wallet-adapter-react. 37 | 38 | ```tsx 39 | 44 | 45 | 46 | ``` 47 | 48 | ### `useSolanaAuth` 49 | 50 | A React hook that exposes the following methods: 51 | 52 | - **`checkIsAuthenticated`**: Returns a boolean value indicating if the user is authenticated. 53 | - **`authenticate`**: A function that tries to authenticate the user. 54 | - **`getAuthData`**: Returns the authentication data, including the signature, public key, and the timestamp of the signature in seconds. 55 | 56 | ```tsx 57 | const { checkIsAuthenticated, authenticate, getAuthData } = useSolanaAuth(); 58 | ``` 59 | 60 | ### `AuthStorage` 61 | 62 | An object that represents the authentication data stored in the local storage of the browser. 63 | 64 | ```typescript 65 | type AuthStorage = { 66 | signature: string; 67 | pubkey: string; 68 | signedAt: number; 69 | }; 70 | ``` 71 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nagaprasadvr/solana-react-auth/f7567278ffd52ee982bdafe823458d2fb280d149/bun.lockb -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "solana-react-auth", 3 | "description": "Solana React Auth", 4 | "license": "MIT", 5 | "author": { 6 | "name": "Nagaprasad V R", 7 | "email": "nagaprasadvr246@gmail.com" 8 | }, 9 | "type": "module", 10 | "main": "dist/index.js", 11 | "types": "dist/index.d.ts", 12 | "version": "2.0.5", 13 | "scripts": { 14 | "build": "bun build src/index.ts --outdir ./dist --minify --format esm --external react --external react-dom " 15 | }, 16 | "devDependencies": { 17 | "@types/bun": "latest", 18 | "@types/react": "^18.3.11" 19 | }, 20 | "peerDependencies": { 21 | "typescript": "^5.0.0" 22 | }, 23 | "dependencies": { 24 | "@solana/web3.js": "^1.95.4", 25 | "bs58": "^6.0.0", 26 | "buffer": "^6.0.3", 27 | "react": "^18.3.1", 28 | "react-dom": "^18.3.1", 29 | "tweetnacl": "^1.0.3" 30 | }, 31 | "publishConfig": { 32 | "access": "public" 33 | }, 34 | "repository": { 35 | "type": "git", 36 | "url": "https://github.com/NagaprasadVr/solana-react-auth.git" 37 | }, 38 | "keywords": [ 39 | "solana", 40 | "react", 41 | "auth", 42 | "web3" 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /src/SolanaAuthProvider.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | useCallback, 4 | useContext, 5 | useEffect, 6 | useState, 7 | type ReactNode, 8 | } from "react"; 9 | 10 | import { type WalletContextState } from "@solana/wallet-adapter-react"; 11 | 12 | import bs58 from "bs58"; 13 | import solanaCrypto from "tweetnacl"; 14 | 15 | export type SolanaAuthProviderProps = { 16 | children: ReactNode; 17 | message: object | string; 18 | wallet: WalletContextState; 19 | authTimeout: number; // in seconds 20 | }; 21 | 22 | export interface SolanaAuthContextType { 23 | checkIsAuthenticated: (wallet: WalletContextState) => boolean; 24 | authenticate: (wallet: WalletContextState) => void; 25 | getAuthData: () => AuthStorage | null; 26 | } 27 | 28 | const defaultContext: SolanaAuthContextType = { 29 | checkIsAuthenticated: () => false, 30 | authenticate: () => {}, 31 | getAuthData: () => null, 32 | }; 33 | 34 | const SolanaAuthContext = createContext(defaultContext); 35 | 36 | export type AuthStorage = { 37 | signature: string; 38 | pubkey: string; 39 | signedAt: number; // timestamp in seconds 40 | }; 41 | 42 | export const SolanaAuthProvider = ({ 43 | children, 44 | message, 45 | wallet, 46 | authTimeout, 47 | }: SolanaAuthProviderProps) => { 48 | const [pubkey, setPubkey] = useState(null); 49 | 50 | // set this when the pubkey is available 51 | useEffect(() => { 52 | if (wallet?.connected && wallet?.publicKey) { 53 | setPubkey(wallet.publicKey.toBase58()); 54 | } 55 | 56 | if (!wallet?.connected) { 57 | setPubkey(null); 58 | } 59 | }, [wallet?.connected, wallet?.publicKey]); 60 | 61 | // if pubkey changes, re-authenticate 62 | useEffect(() => { 63 | authenticate(); 64 | }, [pubkey]); 65 | 66 | const checkIsAuthenticated = useCallback((): boolean => { 67 | try { 68 | if (!wallet.connected) { 69 | return false; 70 | } 71 | 72 | if (!pubkey) { 73 | return false; 74 | } 75 | 76 | const storedAuth = getstoredAuth(); 77 | if (!storedAuth) { 78 | return false; 79 | } 80 | 81 | const isSignatureValid = verifySignature( 82 | storedAuth.signature, 83 | pubkey, 84 | getJsonMessage(pubkey, message) 85 | ); 86 | 87 | if (!isSignatureValid) { 88 | return false; 89 | } 90 | 91 | const now = getTimeNowInSeconds(); 92 | const signedAt = storedAuth.signedAt; 93 | const elapsed = now - signedAt; 94 | 95 | return elapsed < authTimeout; 96 | } catch (e) { 97 | console.error(e); 98 | return false; 99 | } 100 | }, [pubkey]); 101 | 102 | const authenticate = useCallback(async () => { 103 | try { 104 | console.log("authenticating...", pubkey); 105 | if (!pubkey) { 106 | return; 107 | } 108 | 109 | // if already authenticated, no need to re-authenticate 110 | if (checkIsAuthenticated()) { 111 | return; 112 | } 113 | 114 | // sign the message 115 | const signedMessage = await signMessage( 116 | getJsonMessage(pubkey, message), 117 | wallet 118 | ); 119 | 120 | // store the signature on local storage 121 | storeSignature(signedMessage, pubkey); 122 | } catch (e) { 123 | console.error(e); 124 | } 125 | }, [pubkey]); 126 | 127 | const getAuthData = () => { 128 | return getstoredAuth(); 129 | }; 130 | 131 | return ( 132 | 139 | {children} 140 | 141 | ); 142 | }; 143 | 144 | export const useSolanaAuth = () => { 145 | return useContext(SolanaAuthContext); 146 | }; 147 | 148 | function minimizePubkey(pubkey: string) { 149 | return pubkey.slice(0, 5) + "..." + pubkey.slice(-5); 150 | } 151 | 152 | function encodeWithBase58(sig: Uint8Array) { 153 | return bs58.encode(sig); 154 | } 155 | 156 | function decodeWithBase58(sig: string) { 157 | return bs58.decode(sig); 158 | } 159 | 160 | function getMessageToSign(jsonMessage: object) { 161 | return new TextEncoder().encode(JSON.stringify(jsonMessage)); 162 | } 163 | 164 | function verifySignature( 165 | signature: string, // base58 encoded 166 | pubkey: string, // base58 encoded 167 | jsonMessage: object 168 | ) { 169 | return solanaCrypto.sign.detached.verify( 170 | getMessageToSign(jsonMessage), 171 | decodeWithBase58(signature), 172 | decodeWithBase58(pubkey) 173 | ); 174 | } 175 | 176 | async function signMessage(jsonMessage: object, wallet: WalletContextState) { 177 | if (!wallet.publicKey) { 178 | throw new Error("Wallet public key is not available"); 179 | } 180 | 181 | if (!wallet.signMessage) { 182 | throw new Error("Wallet does not support signing messages"); 183 | } 184 | 185 | const encodedMessage = getMessageToSign(jsonMessage); 186 | const signature = await wallet.signMessage(encodedMessage); 187 | 188 | return encodeWithBase58(signature); 189 | } 190 | 191 | function getJsonMessage(pubkey: string, jsonMessage: any): object { 192 | if (typeof jsonMessage === "string") { 193 | const message = { 194 | message: jsonMessage, 195 | pubkey: minimizePubkey(pubkey), 196 | }; 197 | 198 | return message; 199 | } 200 | 201 | if (!jsonMessage["pubkey"]) { 202 | jsonMessage["pubkey"] = minimizePubkey(pubkey); 203 | } 204 | return jsonMessage; 205 | } 206 | 207 | function storeSignature(signature: string, pubkey: string) { 208 | const authStorage: AuthStorage = { 209 | signature, 210 | pubkey, 211 | signedAt: getTimeNowInSeconds(), 212 | }; 213 | 214 | localStorage.setItem("authStorage", JSON.stringify(authStorage)); 215 | } 216 | 217 | function getstoredAuth(): AuthStorage | null { 218 | const authStorage = localStorage.getItem("authStorage"); 219 | if (!authStorage) { 220 | return null; 221 | } 222 | 223 | return JSON.parse(authStorage); 224 | } 225 | 226 | function getCheckedAuthTimeout(authTimeout: number) { 227 | const secondsInDay = 24 * 60 * 60; 228 | if (authTimeout > secondsInDay) { 229 | return secondsInDay; 230 | } 231 | return authTimeout; 232 | } 233 | 234 | function getTimeNowInSeconds() { 235 | return Math.floor(Date.now() / 1000); 236 | } 237 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | SolanaAuthProvider, 3 | useSolanaAuth, 4 | AuthStorage, 5 | } from "./SolanaAuthProvider"; 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "declaration": true, 5 | "emitDeclarationOnly": true, 6 | "lib": ["ESNext", "DOM"], 7 | "target": "ESNext", 8 | "module": "ESNext", 9 | "moduleDetection": "force", 10 | "jsx": "react-jsx", 11 | "outDir": "./dist", 12 | "allowJs": true, 13 | "moduleResolution": "Node", 14 | 15 | // Best practices 16 | "strict": true, 17 | "skipLibCheck": true, 18 | "noFallthroughCasesInSwitch": true, 19 | "allowSyntheticDefaultImports": true, 20 | 21 | // Some stricter flags (disabled by default) 22 | "noUnusedLocals": false, 23 | "noUnusedParameters": false, 24 | "noPropertyAccessFromIndexSignature": false 25 | }, 26 | 27 | "include": ["src/**/*"] 28 | } 29 | --------------------------------------------------------------------------------