The back end for this site is Supabase, my current weapon of choice for simple projects.
So let's build a way to "log in" to Supabase with a Solana wallet.
I've YOLO'd this into production on this site, because at this stage, the only threat is that someone sets an NFT avatar for a public key which they don't have the private key for, on a website that no one reads. You may not want to do this.
Note: you could easily adapt this to work with Hasura or any other service which supports JWT authentication.
This is heavily based on the hard work of other people:
Ethereum Improvement Proposal 4361
Using Next.js and Auth0 with Supabase
Thanks!
1. The client requests a nonce
2. The API responds with a recent Solana block number
3. The client signs the message
4. The API verifies the message, and checks that the Solana block was created in the last 60 seconds
5. The API signs a JWT containing the public key of the user, using the same JWT secret as our Supabase instance
6. The client uses that JWT to authenticate with our Supabase instance
We're using Nuxt API routes to handle the nonce, verification and JWT signing, though you could do this in any number of ways – a Next.js API routes, a Cloudflare Worker, or any a monolithic back end.
Easy peasy. Here's server/api/nonce.js
:
import { Connection, clusterApiUrl } from '@solana/web3.js' export default async (req, res) => { let connection = new Connection(clusterApiUrl('mainnet-beta'), 'confirmed') let recentBlockhash = await connection.getRecentBlockhashAndContext() return { nonce: recentBlockhash.context.slot } }
async signMessage() { const { nonce } = await $fetch('/api/nonce', { method: 'POST' }) const message = `Sign this message to log in to stillearly.io // ${nonce}` const data = new TextEncoder().encode(message) const signature = await this.adapter.signMessage(data, 'hex') const verified = await $fetch('/api/verify', { method: 'POST', body: { publicKey: this.adapter.publicKey.toBase58(), nonce: nonce, signature: base58.encode(signature) } }) if (verified.accessToken) { this.accessToken = verified.accessToken } }
Ideally, we'd give the user some feedback if something goes wrong here.
Here's server/api/verify.js
:
import config from '#config' import base58 from 'bs58' import nacl from 'tweetnacl' import jwt from 'jsonwebtoken' import { useBody } from 'h3' import { $fetch } from 'ohmyfetch' import { Connection, clusterApiUrl } from '@solana/web3.js' const BLOCK_VALID_FOR_N_SECONDS = 60 export default async (req, res) => { const body = await useBody(req) const signatureUint8 = base58.decode(body.signature) const pubKeyUint8 = base58.decode(body.publicKey) // is the nonce a legit recent block? const connection = new Connection(clusterApiUrl('mainnet-beta'), 'confirmed') const blockTime = await connection.getBlockTime(body.nonce) const now = Date.now() / 1000 const isRecent = (blockTime > (now - BLOCK_VALID_FOR_N_SECONDS)) ? true : false const nonceUint8 = new TextEncoder().encode(`Sign this message to log in to stillearly.io // ${body.nonce}`) const verified = nacl.sign.detached.verify(nonceUint8, signatureUint8, pubKeyUint8) && isRecent let accessToken = null if (verified) { // create a row in our user table (if it's not there already) const url = `${config.SUPABASE_URL}/rest/v1/user` const res = await $fetch(url, { method: 'POST', headers: { apikey: config.SUPABASE_API_KEY, authorization: `Bearer ${config.SUPABASE_SERVICE_ROLE}`, 'content-type': 'application/json; charset=utf-8', prefer: 'resolution=merge-duplicates' }, body: JSON.stringify({ public_key: body.publicKey }) }) // sign jwt const payload = { publicKey: body.publicKey, exp: Math.floor(Date.now() / 1000) + 60 * 60, } const accessToken = jwt.sign(JSON.stringify(payload), config.SUPABASE_JWT_SECRET) return { accessToken: accessToken } } return { verified: verified, } }
Note: if you're using Nuxt like me, ensure that SUPABASE_SERVICE_ROLE
and any other secrets are in your privateRuntimeConfig
in nuxt.config.js
.
privateRuntimeConfig: { SUPABASE_JWT_SECRET: process.env.SUPABASE_JWT_SECRET, SUPABASE_SERVICE_ROLE: process.env.SUPABASE_SERVICE_ROLE },
I already had a public.user
table with a public_key
column set as the primary key.
The next thing to do was create an auth function we can use to get the public key from the JWT.
create or replace function auth.public_key() returns text as $$ select nullif(current_setting('request.jwt.claim.publicKey', true), '')::text; $$ language sql stable;
Now to add a policy to the public.user
table. This assumes you already have row-level security enabled. You can do all of this via the Supabase UI if you want.
create policy "enable update for users based on public key" on public.user for update using (auth.public_key() = public_key) with check (auth.public_key() = public_key);
using (auth.public_key() = public_key)
ensures that the user can't update any rows in the table which don't have their public key in the public_key
column. with check
ensures that the user can't change a row which does have their public key into one that doesn't.
For more details you can check out the create policy documentation.
Assuming you're using Supabase like this:
const supabase = createClient(FOO, BAR)
Just add this:
supabase.auth.setAuth(THE_JWT)
In my case, now you can choose an avatar from NFTs which you own.
Now to actually do something with them. Until next time!