Using An EVM Wallet For IndieAuth đȘȘ
by
Published:
Posted to:
How I use MetaMask to authenticate to IndieAuth sites
Tags:
Astro
how-to
IndieWeb
Hello! In this post I will describe how I implemented an IndieAuth authorization endpoint that uses MetaMask to authenticate as my domain.
The Why
When I first heard of the IndieWeb, I became enamored with the general idea of âowningâ my content and sharing (syndicating) it with sites that I choose. This is in direct contrast with how most social media operates. The IndieWeb model feels like many other âbest practicesâ across multiple domains such as Information Security (single source of truth), Programming (reusable component architecture), Design (donât repeat yourself or DRY), etc.
Granted, I donât have a lot of content at this point, but I am hoping that is going to change. However, I digressâŠ
One of the many pieces of the IndieWeb solution is IndieAuth. From the IndieAuth page linked at the top of this post:
IndieAuth is a federated login protocol for Web sign-in, enabling users to use their own domain to sign in to other sites and services. IndieAuth can be used to implement OAuth2 login AKA OAuth-based login.
Sounds fancy, right? There are already multiple ways to set up a domain to use with IndieAuth. Web sign-in is a popular option supported by a number of website platforms or if you are using a solution that doesnât have built-in support or an existing plugin/module you can set up RelMeAuth and link to your social profiles and have one of those platforms (i.e., GitHub) perform the authentication for you.
An example could look like:
<a href="https://github.com/sugardave" rel="me">@sugardave on Github</a></li>
Or, if you prefer âinvisibleâ links, you can add them in the head of your root page like:
<link rel="me" href="https://github.com/sugardave" />
You must link back to your homepage from the social profile. Otherwise anyone could use their domain to authenticate as yours. No bueno.
I can hear you asking âBut, doesnât this step on the âown your own stuffâ bit?â Well, it all depends on how you look at it. For example, GitHub is probably not going to go away anytime soon and it may be worth leveraging them for the time saved implementing your own authentication backend. But these are the kinds of questions that make me want to investigate other options.
A tried and true method of authentication remains Ye Olde Username And Password(e) (YOUP). Unfortunately, this requires a system for storing credentials (usually a database) and may be more work than youâre wanting to do just so you (or actually I) can prove that youâre (Iâm) sugardave.cloud. In fact, I have no plans for user login at this site, so a full-on database and authentication system is something I want to avoid.
The How
Once the decision was made to eschew the RelMeAuth and YOUP methods, I dug a bit further into the IndieAuth specification. Like a lot of specification documentation, this one is a bit dry. However, it is quite informative.
The Discovery section details what you need to have in order for a client (app, web app, web site, etc.) to discover your authorization endpoint. Without this, the client has no idea how to authenticate your domain. Using a Link
header or <link>
tag, the requesting client will fetch metadata that describes the following properties (at least):
- issuer - this should be the domain you are authenticating as
- authorization_endpoint - where the client will make a request to start the authentication or authorization flow(s)
- token_endpoint - where various access token operations can be performed
- code_challenge_methods_supported - supported methods the client can use to calculate a code challenge
Great! Letâs get to some specifics.
Implementation
This site is created with Astro. I wonât go deeply into its features, but first and foremost it excels as a content-driven static site framework. You know, like for a blog! You can also configure it such that some or all of your site is server-rendered. So, there are many ways to skin the proverbial cat.
Allowing Discovery
The first thing I did was add the discovery information to my siteâs root layout.
<head>
...
<!-- IndieAuth discovery -->
<link
rel="indieauth-metadata"
type="application/json"
href="/indieauth/1.0/metadata.json"
/>
</head>
Next, using an Astro API endpoint I created src/pages/indieauth/1.0/metadata.json.ts
:
const {SITE, BASE_URL} = import.meta.env;
// SITE = 'sugardave.cloud'
// BASE_URL = '/'
const issuer = `${SITE}${BASE_URL}`;
const metadata = {
issuer,
authorization_endpoint: `${issuer}indieauth/1.0/auth/`,
token_endpoint: `${issuer}indieauth/1.0/token/`,
code_challenge_methods_supported: ['S256']
};
export const GET = () => {
return Response.json(metadata, {
headers: {
status: '200'
}
});
};
Astroâs build system will strip that fileâs extension and, once deployed, clients will be able to make a GET request to https://sugardave.cloud/indieauth/1.0/metadata.json to access it as JSON. Feel free to hit the link and check it yourself, I promise it works!
Armed with metadata the client can perform the next step.
Authentication
Authentication? Isnât the endpoint for âauthorizationâ? Yep! Itâs actually for both. Consider the names OAuth, IndieAuth, WhateverAuth and be amazed when I tell you thereâs a reason why everyone just calls them âauthâ. That reason is a combination of ambiguity and laziness.
Moving onâŠ
I have another endpoint file, src/pages/indieauth/1.0/auth.ts
:
import type {APIContext} from 'astro';
export const GET = async (context: APIContext) => {
const {redirect, session, url} = context;
const searchParams: AuthRequestParams = Object.fromEntries(
url.searchParams.entries()
);
const search = url.search;
const {code: authorizationCode, me, redirect_uri, state} = searchParams;
if (!authorizationCode) {
const authPage = new URL(`/auth${search}`, url.origin);
return redirect(authPage.toString(), 302);
} else {
// store session data in indieAuth session
await session?.load('indieAuth');
session?.set('indieLogin', {authorizationCode, me, state});
return redirect(
`${redirect_uri}?code=${authorizationCode}&state=${state}&iss=${me}`,
302
);
}
};
export const prerender = false;
This time the build system will give me /indieauth/1.0/auth
where a client can make a GET request to either exchange an authorization code (if it has one) or be redirected to another page to attempt to obtain one.
Thatâs what the following piece of the above code does:
if (!authorizationCode) {
const authPage = new URL(`/auth${search}`, url.origin);
return redirect(authPage.toString(), 302);
}
When the client doesnât provide an authorization code, I need to start the actual business of authenticating myself. The page I redirect to is created at src/pages/auth.astro
:
---
import AuthLayout from '@layouts/AuthLayout.astro';
import {IndieAuthLogin} from '@components/IndieAuthLogin';
const {INDIEAUTH_EVM_WALLET_ADDRESSES: walletAddresses} = import.meta.env;
const {url} = Astro;
const searchParams: AuthRequestParams = Object.fromEntries(
url.searchParams.entries()
);
const walletWhitelist: string[] = walletAddresses
.split(',')
.map((address: string) => address.trim().toLowerCase());
export const prerender = false;
---
<AuthLayout>
<IndieAuthLogin
client:only="react"
authRequestParams={searchParams}
walletWhitelist={walletWhitelist}
/>
</AuthLayout>
There are a couple of things to note here. I am using React via an Astro integration. This is because, among other reasons, I am using a package called wagmi to ease EVM wallet operations and it requires a couple of React context providers.
Because I am using session data to store an authorization code for comparison later and that requires a way to save and retrieve session information, I am deploying my site to Netlify using another Astro integration and since I am also using a wallet whitelist so that not just anyone with an EVM signer can successfully authenticate as me, I needed to make sure that the INDIEAUTH_EVM_WALLET_ADDRESSES
environment variable is defined for my Netlify deployment using their dashboard.
The <IndieAuthLogin>
component is where I have the required context provider wrappers for wagmi. It also contains an onSuccess
handler passed to the wrapped component, <LoginPage>
. This handler is responsible for generating the authorization code after I have successfully signed a message (authenticated) with my whitelisted wallet as well as storing the generated code in session storage.
Letâs take a closer look at src/components/IndieAuthLogin/IndieAuthLogin.tsx
:
import {WagmiProvider} from 'wagmi';
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
import {LoginPage} from './LoginPage';
import wagmiConfig from '../../lib/wagmiConfig';
interface IndieAuthLoginProps {
authRequestParams: AuthRequestParams;
walletWhitelist: string[];
}
const onError = ({error}: {error: Error}) => {
console.error(`IndieAuthLogin ${error}`);
};
const onSuccess = async ({address, ...rest}: {address: string}) => {
// Redirect or perform any other actions after successful login
const authorizationCode = generateAuthorizationCode();
const {me, redirect_uri, state} = rest as AuthRequestParams;
const authUrl = new URL('/indieauth/1.0/auth', window.location.origin);
const sessionData = new URL(
'/indieauth/1.0/sessionData',
window.location.origin
);
// save session data
await fetch(sessionData.toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
address,
authorizationCode,
me,
state
})
});
authUrl.searchParams.set('code', authorizationCode);
authUrl.searchParams.set('me', me!);
authUrl.searchParams.set('redirect_uri', redirect_uri!);
authUrl.searchParams.set('state', state!);
window.location.href = authUrl.toString();
};
const generateAuthorizationCode = (length = 32) => {
// Define the characters to use for the authorization code
const characters =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let authorizationCode = '';
// Generate a secure random string
for (let i = 0; i < length; i++) {
const randomIndex = Math.floor(Math.random() * characters.length);
authorizationCode += characters[randomIndex];
}
return authorizationCode;
};
const queryClient = new QueryClient();
const IndieAuthLogin = ({
authRequestParams,
walletWhitelist
}: IndieAuthLoginProps) => {
return (
<WagmiProvider config={wagmiConfig}>
<QueryClientProvider client={queryClient}>
<LoginPage
authRequestParams={authRequestParams}
onError={onError}
onSuccess={({address}) => {
onSuccess({address, ...authRequestParams});
}}
walletWhitelist={walletWhitelist}
/>
</QueryClientProvider>
</WagmiProvider>
);
};
export default IndieAuthLogin;
export {IndieAuthLogin};
After signing the message with my whitelisted wallet, the handler will redirect to the /indieauth/1.0/auth
endpoint again, but this time with the code provided in the query parameters, which in turn will redirect to the clientâs redirect_uri
it provided when this authentication flow was started.
Signing A Message With MetaMask
The <LoginPage>
component has a lot of code, so I wonât paste it all here. I am using another package, siwe, and its SiweMessage
class to perform message signing operations. The important part of my component is the signIn
method:
// create a message with nonce and sign it
const signIn = async () => {
try {
const domain = window.location.host;
const statement = `Sign in with Ethereum to IndieAuth.`;
const uri = window.location.origin;
const version = '1';
if (!address || !nonce) {
return;
}
const message = new SiweMessage({
domain,
address,
statement,
uri,
version,
chainId,
nonce
});
const signature = await signMessageAsync({
message: message.prepareMessage()
});
// verify signature
const verifyResponse = await fetch('/indieauth/1.0/verifySignature', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
message,
signature
})
});
if (!verifyResponse.ok) {
throw new Error('Error verifying message');
}
if (!walletWhitelist.includes(address.toLowerCase())) {
throw new Error('Wallet address not authorized');
}
onSuccess({address});
} catch (error) {
onError({error: error as Error});
fetchNonce();
}
};
Things to note in this one are the fetchNonce
method and the /indieauth/1.0/verifySignature
endpoint. The former is accessing another (you guessed it!) endpoint at /indieauth/1.0/getNonce
to get a fresh nonce and store it in the component state with a useState
hook. It is originally called in the useEffect
hook for the component on initial render and again if an error occurs during the signing process. The implementation of the getNonce
endpoint also writes the nonce to session storage so it can be retrieved for use when it is time to verify the signature (the nonce value is stored with the message that will be signed).
The latter is responsible for verifying that the message signature is valid.
Token Exchange
This part of the flow can be a little confusing and it may not be necessary, but I have included it for some semblance of completeness. Luckily for me, I only care about a yes/no answer that denotes whether I have successfully authenticated as opposed to more granular authorization for actions I may want to be able to perform on the requesting clientâs platform.
Another endpoint does this at src/pages/indieauth/1.0/token.ts
:
import type {APIContext} from 'astro';
export const POST = async (context: APIContext) => {
const {request, session} = context;
const body = await request.text();
const params = new URLSearchParams(body);
const json: AuthRequestParams = Object.fromEntries(params.entries());
const {code} = json;
await session?.load('indieAuth');
const {authorizationCode, me} = (await session?.get('indieLogin')) || {};
return new Response(
JSON.stringify(
authorizationCode === code
? {me}
: {error: 'Authorization code does not match'}
),
{
status: 200,
headers: {'Content-Type': 'application/json'}
}
);
};
export const prerender = false;
Now I have an endpoint at /indieauth/1.0/token
that processes a POST request made by the client. It is sending the authorization code to me and I need to compare it to the one that I sent to the client when the flow began. Itâs a good thing I saved that code in the server session data, right? Upon a successful comparison, my profile information (the {me} object from the session) is returned to the client as proof that I am who I say I am.
Wrapping Up
After putting it all together, I can now authenticate as sugardave.cloud to any client that uses IndieLogin, huzzah!