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.
- I enter my profile URL in the login form of the client and click "Sign in"
- The client discovers my authorization endpoint by fetching my profile URL and looking for the
rel=authorization_endpoint
value. - 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.
- 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)
- The authorization endpoint prompts me to log in and asks whether to grant or deny the client's authentication request.
- 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.
- 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 toid
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:
After logging in, the only change is that I replaced the login link with a button to authenticate the request, here is the screenshot:
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.
id
: for me to query them later.client_id
: The client requesting for authentication.redirect_uri
: The redirect URI send by the client.code
: a unique authentication/authorization code.created_on
: The date and time of creating this row.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 😄.