Adding IndieAuth to my website

A single user IndieAuth is surprisingly easy to implement! This post is about how I did it.

  • Published on .
  • Last updated on .
  • Tagged under

Note: Most code examples in this page are in Rust and in the Handlebars template. But that shouldn't be a problem to follow this, the base logic remains the same, regardless of what language you use, the Rust just helps me avoid a few unnecessary checks.

In my last post, I wrote about adding Webmentions to this website. It ended with me saying that I will add IndieAuth support next. But as I was busy being laid off and searching for a job (I'm still searching for one), I couldn't really find much time to do it. But I also wanted a new blog post to come out at every month, so here I am trying to keep my promise.

The basics

The first step is to read the IndieAuth specification. I will ignore the client-side parts in this blog post, as they are irrelevant to me.

What I am going to do is a single-user authentication. Since I am not allowing others to login through my website, the process is even simpler. It should be possible to change this implementation to handle multi-user authentication without much effort.

For us to login to other IndieWeb webistes/apps (clients), we need an authorization endpoint, and to allow publishing through a client, we need a token endpoint.

Currently, I publish all posts on my website as HTML files in handlebars template format. Due to this, token endpoint does not make sense for me to implement right now, but I am updating this website more regularly now, so it might be a thing I do in the future!

Authentication

Authentication is the act of validating that users are whom they claim to be. The IndieAuth specification tells us how this works, let us note it down first.

The endpoint that handles the request is an authorization endpoint instead of an authentication endpoint. This is because both authentication and authorization go through the same route at the beginning.

In the following list, the client would be a website or app that I am logging on to, like indieweb.org.

  1. I enter my profile URL in the login form of the client and click "Sign in"
  2. The client discovers my authorization endpoint by fetching my profile URL and looking for the rel=authorization_endpoint value.
  3. The client builds the authentication request including its client identifier, local state (a random string), and a redirect URI, and redirects the browser to the authorization endpoint I am creating now.
  4. The authorization endpoint fetches the client information from the client identifier URL to have an application name and icon to display to me. (I might will this and just show the URLs to avoid complexity)
  5. The authorization endpoint prompts me to log in and asks whether to grant or deny the client's authentication request.
  6. The authorization endpoint generates an authorization code and redirects the browser back to the client, including the local state in the URL. This is called an authentication response.
  7. The client verifies the authorization code by making a POST request to the authorization endpoint. The authorization endpoint validates the authorization code, and responds with the End-User's canonical profile URL.

You can see that half of the steps are done from the client side, so it is not as complex as it looks.

The authorization code we need to generate could be anything, the specification has not mandated a format or length, so I am thinking of just using a UUID for this.

Implementing Authentication

Now I know what needs to be done, and where to look for the specification, so it's time to do some coding.

Updating the existing login

I have an existing login implementation, this is a username and password-based authentication and I use it for moderating webmentions (previously comments). The password is hashed and salted. Once logged in, I store the login status in a private cookie.

To support the authorization confirmation screen that I will develop later, I need to modify the login page to redirect to a URL path when present, so I will start with that.

First, I will accept a new optional string parameter named redirect_path in the login request parameter, I will use this to redirect to the authorization endpoint.

#[get("/login-to-site?<redirect_path>")]
async fn login_page(redirect_path: Option<&str>, ... ) -> Template {
  ...
    Template::render("admin/login-to-site",
      ...
      page {
        ...
        redirect_path
        ...
      }
      ...
    )
 ...
}

I used the redirect_path in my template as a hidden input element. Maybe I should inform myself about this redirection, but I don't think that will be a problem as there are more manual steps to be done afterwords and I also do security checks later.

{{#if page.redirect_path}}
    <input type="hidden" name="redirect_path" value={{page.redirect_path}}>
{{/if}}

If you are interested, you can see these changes in my commit history.

I now need to redirect to this URL when the login POST request is successful, but I do not want to redirect to any page that comes up in the URL. I need all redirections to be within my website domain. This is as simple as making sure that the path starts with a forward slash (/). So I wrote a helper method to handle all in-page redirections.

fn redirect_to_path(mut optional_path: Option<String>, default: String) -> Redirect {
  let path = optional_path.take_if(|path| path.starts_with("/"));
  Redirect::to(path.unwrap_or(default))
}

The idea is to make the login page redirect back to the authorization page, and do a final confirmation within the authorization page that will do the authentication response.

Creating the Authentication page

This is the page we would land on after trying to sign in through a client using my domain URL. I want this to be as simple as possible, and I am also thinking of adding the token endpoint support to it later.

Anyway, I went back and read the specification again to get an example of what we will get from the client, and it is a GET request like this:

https://example.org/auth?me=https://user.example.net/&
                          redirect_uri=https://app.example.com/redirect&
                          client_id=https://app.example.com/&
                          state=1234567890&
                          response_type=id

Looking at the specification, I know the following:

  • The parameter me should be my domain URL in its canonical form (e.g. https://codingotaku.com/).
  • The redirect_uri is the URI I need to redirect to after authorization.
  • The client_id is the URI of the app
  • The state is a value that I need to send to the client as it is to avoid cross-site request forgery.
  • And finally, the optional response_type that defaults to id means that this is an authentication request.

If the response_type is code, that means that it is an authorization request instead of an authentication request, we will handle this later, for now, I will treat all requests as an authentication request.

Ideally, the redirect_uri would be using the same host and port as the client_id. But this might not be the case always. So we need to ensure that the client supports the redirect_uri provided by crawling the client. But I decided to just accept those requests in the initial version, and add that in after finding a good HTML parsing library for rust.

As the specification tells, it is important to show as much detail as possible in authorization page. My idea is to display the response_type, client_id, and the redirect_uri on the page. So that I can do a manual verification before logging in. This way, I can catch the abnormalities, like client_id and redirect_uri not matching.

The page code will look something like this (written in handlebars template):

<p>You are receiving a request to authenticate to <a href="{{page.client_id}}">{{page.client_id}}</a></p>
<p>After authentication, this page will be redirected to <code>{{page.redirect_uri}}</code></p>
<p>If this request looks suspicious, please manually verify them, or avoid authenticating the request.</p>

<h2>Request details</h2>
<dl>
  <dt><strong>Client ID</strong></dt>
  <dd>{{page.client_id}}</dd>
  <dt><strong>Redirect URI</strong></dt>
  <dd>{{page.redirect_uri}}</dd>
  <dt><strong>Response type</strong></dt>
  <dd>{{page.response_type}}</dd>
</dl>

The authentication page needs to check whether I am logged in or not, and If I am logged in, a button should be shown to do the authentication; otherwise, I should be asked to log in.

{{#if settings.is_logged_in}}
<form class="form" action="/authentication" method="post" accept-charset="utf-8" aria-label="Authentication">
  <input type="hidden" name="redirect_uri" value={{page.redirect_uri}}>
  <input type="hidden" name="response_type" value={{page.response_type}}>
  <input type="hidden" name="state" value={{page.state}}>
  <input type="hidden" name="client_id" value={{page.client_id}}>
  <input type="hidden" name="me" value={{page.me}}>
  <button type="submit" class="submit-button">Authenticate</button>
</form>
{{else}}
  <p class="space-out"><a class="link-button" href="/login-to-site?redirect_path={{page.escaped_uri}}">Login to Authenticate this request</a></p>
{{/if}}

Here is a screenshot of the authentication page before logging in:

Screenshot before logging in

The authentication request page has the client ID, redirect URI, and the response type with a link to login. I placed a warning to not proceed if the request looks suspicious.

After logging in, the only change is that I replaced the login link with a button to authenticate the request, here is the screenshot:

Screenshot after logging in

Same content as the previous screenshot, with the link replaced with an Authenticate button placed in the centre.

The complete page template can be found in my repository.

Creating a table to store access codes

To keep track of the authentication requests, I will create a new table named indie_auth_code. Within this, I will add all six columns that I will be needing for authentication.

  1. id: for me to query them later.
  2. client_id: The client requesting for authentication.
  3. redirect_uri: The redirect URI send by the client.
  4. code: a unique authentication/authorization code.
  5. created_on: The date and time of creating this row.
  6. expire_on: the date and time when the code expires.

I use SQLite for storing things on my website, and the table schema looks like this:

CREATE TABLE indie_auth_code (
  id TEXT NOT NULL,
  client_id TEXT NOT NULL,
  redirect_uri TEXT NOT NULL,
  code TEXT NOT NULL,
  created_on DATETIME NOT NULL,
  expires_on DATETIME NOT NULL,
  UNIQUE(code)
);

Authentication endpoint

Now that we have a table, we reached the main part of the whole process. It is time to do the actual authentication.

I created a new endpoint named authentication. This receives a POST request that contains all values that will be sent by the client. The response will always be a redirection.

#[derive(FromForm)]
pub(crate) struct IndieAuthForm {
    pub me: String,
    pub client_id: String,
    pub redirect_uri: String,
    pub state: String,
    pub response_type: Option<String>,
}

#[post("/authentication", data = "<indie_auth>")]
async fn authentication(indie_auth: Form<IndieAuthForm>) -> Redirect {
  ...          
}

You will notice that reponse_type is an Option, this is because it is an optional parameter that defaults to the string id.

The authentication endpoint will first ensure that both the client_id and rediect_uri are of the same host and port. I wrote a few helper methods for this:

fn is_port_matching(source: Option<u16>, target: Option<u16>) -> bool {
    (source.is_none() && target.is_none())
        || (source.is_some_and(|_| target.is_some()) && source.unwrap().eq(&target.unwrap()))
}

fn is_host_matching(source: &str, target: &str) -> bool {
    !(source.is_empty() || target.is_empty() || source.ne(target))
}

fn get_url_authority(uri_value: &str) -> Option<Authority> {
    let url = uri::Absolute::parse(uri_value);

    url.map_or(None, |url| url.authority().cloned())
}

fn is_authority_matching(source_uri: &str, target_uri: &str) -> bool {
    let source = get_url_authority(source_uri);
    let target = get_url_authority(target_uri);

    source.is_some_and(|source_val| {
        target.is_some_and(|target_val| {
            is_host_matching(source_val.host(), target_val.host())
                && is_port_matching(source_val.port(), target_val.port())
        })
    })
}

The helper methods will handle all edge cases of varying ports and host names, but if they differ, I will need to crawl the client to find the redirect_uri. As I mentioned before, I do this manually now. I'll update this blog once I find time to do a proper check.

Once the first check was done, I generated a new UUID, and stored it in the indie_auth_code table we created before. We already have all the details we need, the created_on is the current time and expires_on will be around 5 minutes past the current time.

let code = Uuid::new_v4().to_string();
let now = OffsetDateTime::now_utc();

let auth_code = IndieAuthCode {
    id: Uuid::new_v4().to_string(),
    client_id: indie_auth.client_id.clone(),
    redirect_uri: indie_auth.redirect_uri.clone(),
    code: code.clone(),
    created_on: now.format(&well_known::Iso8601::DEFAULT).unwrap(),
    expires_on: now
        .checked_add(Duration::minutes(5))
        .unwrap()
        .format(&well_known::Iso8601::DEFAULT)
        .unwrap(),
};

add_auth_code(&mut db, &auth_code).await;

The specification suggests a maximum of 10 minutes for the expiration time, but let us play a little safe and keep it at 5. I want it to change the time limit via a config or something in the future.

On hindsight, setting time is something I could do from the database itself, but this is what we are going with right now.

I sent it to redirect_uri along with the given state as an HTTP redirect with 302 Found status.

You can find the full implementation with more error handling in my repository.

Everything is coming together, now as per the specification, I need to accept another Authorization Code Verification request being sent by the client to the authorization endpoint.

Authorization Code Verification

The validation part is simple, the client will give us the code, client_id, and the redirect_uri as the POST parameter. We just need to return the profile URL as a JSON to the client after the validating it against the details we saved in the last step within the indie_auth_code table. My response will need to look like this:

{
  "me": "https://codingotaku.com/"
}

In case of errors, I am just sending the error code instead, something like this:

{
  "error": "invald_request"
}

All validations can be done from the database itself, so I wrote this query:

SELECT id, client_id, redirect_uri, code, created_on, expires_on, is_accessed
       FROM indie_auth_code
       WHERE code=[the-received-cde]
       AND client_id=[received-client-id]
       AND redirect_uri=[received-redirect-uri]
       AND datetime(expires_on) > datetime('now');

The query is being used by my authorization endpoint, I also do the redundant checks to ensure that the request is valid before doing the query, this is not really needed, but it can prevent an unnecessary query.

#[post("/indie-auth", data = "<code_verification>")]
async fn auth_verification(
    mut db: Connection<Db>,
    code_verification: Form<IndieAuthCodeVerification>,
    config: &State<AppConfig>,
) -> (Status, Json<IndieAuthResponse>) {
    if !is_authority_matching(
        &code_verification.client_id,
        &code_verification.redirect_uri,
    ) {
        return (
            Status::BadRequest,
            Json(IndieAuthResponse {
                me: None,
                error: Some(String::from("invalid_request")),
            }),
        );
    }

    if let Ok(auth_code) = get_auth_code(&mut db, &code_verification).await {
        delete_auth_code(&mut db, &auth_code.id).await;
        (
            Status::Found,
            Json(IndieAuthResponse {
                me: format!("{}/", config.card.homepage).into(),
                error: None,
            }),
        )
    } else {
        (
            Status::NotFound,
            Json(IndieAuthResponse {
                me: None,
                error: Some(String::from("invalid_grant")),
            }),
        )
    }
}

Using the authorization endpoint

The final step is to actually use this endpoint. Until now, I was using indieauth.com/auth as the endpoint, I updated it to the new codingotaku.com/indie-auth.

Note: If you go to that page without proper request parameters, I will just show you a 404 not found page, this is to trick some of the bad bots that I've been getting.

If you would like to see the complete set of changes, here is the commit difference between the implementations (you'll need to scroll down a bit to see the files).

The issues I faced

My initial approach was to reject the authentication request if the response_type is not id or empty. But all the clients I tried to log in sent me the response_type as code instead. Annoyingly, it is also true for the indieweb.org. It could either be a bug, or they have more features that I can unlock once I add a token endpoint.

In my current implementation, I am just printing this on to the console and ignoring it (I know, shut up). But I will need to handle this by creating a token endpoint later.

Other than this one thing, I haven't really faced any other issues, hope this helps, now I am having 100% IndieWeb implementation done by myself without depending on a service!

Next steps

As I have mentioned a few times in the article, I am still missing a token endpoint. But I have some work already being done on a separate branch to use a database for all the posts. It is a big task as I need to think of better backup systems in case I corrupt the DB.

Once the database work is done, I will be able to add a token endpoint and post without updating the repository all the time 😄.


Mentions

Looking for comments? This website uses webmention instead, the standard way for websites to talk to each other, you can either send me one through your website, or use the below form to submit your mention. If you do not own a website, send me an email instead.

Toggle the webmention form
All webmentions will be held for moderation by me before publishing it.

About me

Coding Otaku Logo

I'm Rahul Sivananda (he/him), the Internet knows me by the name Coding Otaku (they/them). I used to work as a Full-Stack Developer in London, now actively looking for a job after being laid off.

I care about Accessibility, Minimalism, and good user experiences. Sometimes I write stories and draw things.

Get my cURL card with curl -sL https://codingotaku.com/cc
You can find me on Mastodon, Codeberg, or Peertube.

Subscribe to my web feeds if you like what I post and want to get notified. If you would like to help me to keep this site and my silly projects running, you can now donate to me through Liberapay or Stripe!

Continue Reading

Like this post? there's more where that came from! Continue reading.

Recent Blogs

Subscribe via Atom or JSON feeds.