Using An EVM Wallet For IndieAuth đŸȘȘ

by sugardave

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!