DEV Community

Rafal Manka
Rafal Manka

Posted on AI-assisted

Turning Featherboard into a multi-tenant application: GitHub OAuth and path-based routing

The next phase of development is done. At this stage, Featherboard starts to become something you can actually go ahead and host on your own server, one that can support multiple teams and multiple products. User authentication so far is implemented via GitHub OAuth login, but in future phases other providers will be added, as well as regular email login. At this point I've removed the temporary hardcoded token authentication. Also, multi-tenant routing is implemented via the app.featherboard.net/{slug}/... path, where slug is the name of the organization. This part of the implementation required the most debugging on my side, because all links had to be modified and the slug had to be added dynamically to all paths. Fortunately, Axum provides an extractor that allows intercepting requests and extracting organization data from the path very elegantly.

Goodbye to the shared-token hack

So far, as a placeholder, the admin role was granted when a request contained a header with a token that was hardcoded and stored in an environment variable, FEATHERBOARD_ADMIN_TOKEN. Naturally, this is not a sustainable solution and had to be replaced by actual authentication logic. This was replaced by a users table, GitHub OAuth login, and an is_admin flag on the users table. Even though that implementation sounds simple, getting there wasn't as straightforward as I would have liked.

Single binary with embedded static files

In Featherboard I embed all the static files I use throughout the project, so that everything is contained within a single binary file. That includes sqlx migration files as well as HTML templates. The main reason for that decision was that I wanted the hoster not to have to worry about whether they have all the dependencies in the right relative folders. One notable particularity of sqlx migrations is that they are addition-only - if you want to change something in the schema you need to create an additional migration file, because otherwise the migration will be rejected and the app will crash on startup.

Getting UNIQUE indexes right

I added a UNIQUE index on the GitHub account so the user does not register twice. This makes total sense - until you realize that it also prevents a user from creating an account in multiple organizations: if you log in to product A, you won't be able to log in to product B. I realized this during a debugging session, and I was right to try creating an account in another organization - the login failed immediately, as expected. That's not what we wanted, so instead I had to create a compound index on GitHub user id and organization slug.

GitHub does not guarantee to give you an email

In order to create an account within Featherboard, you need an email and an OAuth token. But GitHub allows you to make your email address private, so you need to fall back on the /user/emails endpoint to retrieve the email address if it isn't provided in the original request. The /user/emails endpoint returns a list of emails, and I had to get the primary one from the list. This wasn't really documented properly in GitHub's documentation, and I had to figure it out during debugging as well.

Token upsert as a single operation

GitHub login needs to update the users table, specifically the provider and provider_user_id columns; next, it needs to decide whether to create a new entry or update an existing one based on what's already there. Fortunately, SQLite supports exactly the tool I need for this, specifically the syntax: INSERT ... ON CONFLICT (provider, provider_user_id) DO UPDATE ... RETURNING id; - a single statement that creates or updates a record and returns its id. I could have just used a SELECT to check if a record already exists, and then INSERT or UPDATE respectively, but that wouldn't be as elegant as this single statement.

Multi-tenant routing: the {org-slug} quagmire

Routing was complex and required a lot of changes, because the root of every URL changes in a way that requires me to dynamically inject a path parameter, and also extract that path parameter for every request. Fortunately, Rust's Axum supports extractors, which allowed me to write a simple impl for an extractor that parses the path and creates a CurrentOrg struct from it. Then, in each function handler, I just add that parameter to the handler signature for the extractor to work automatically. It was a very clean and pleasant experience.

None of the bugs I experienced were unusual, and it was quite easy to figure out what I was doing wrong. But even with such a simple-sounding implementation, the amount of work I had to put in was surprisingly large, and at each of those steps I could easily have shot myself in the foot.

Here's the code if you're interested, ready to grab or follow along: https://github.com/RafalManka/Featherboard

Top comments (0)