Building jargons.dev [#4]: The Authentication System
Olabode Lawal-Shittabey
Posted on August 26, 2024
As a developer, Authentication is one of the things that I've got the most respect for; In my experience doing authentication (maybe on a basic level), I've always struggled with one thing or the other especially when I've got to integrate OAuth.
Prior to working on this for jargons.dev, my most recent experience doing Auth was on Hearts where I integrated GitHub OAuth.
So yea! I also had my (traditional 😂) struggles working on this for jargons.dev too; but honestly this was only because of the differences in setup (i.e. technology) though — My experience on Hearts was integrating GitHub OAuth with Server Actions in NextJS meanwhile on jargons.dev, I'm integrating GitHub OAuth with Astro.
The Iterations
As I currently write, the authentication system has gone through 3 Iterations, with more planned (details on next iteration in this issue #30); these iterations over the weeks of development have implemented improvements or refactored a thing or two due to some uncovered limitation.
First Iteration
This iteration implemented in base authentication functionality that allows initiation of a GitHub OAuth flow, response handling that exchanges the authentication code for an accessToken that we securely store on user's cookies.
The imperative changes worth stating about this iteration is that
- I integrated a GitHub App OAuth wich uses permissions with its fine-grained token offering; this promises a short-lived accessToken with a refreshToken.
- I implemented 2 API route for handling the Auth related requests
-
api/github/oauth/callback
- which handles the response from the OAuth flow by redirecting to a specific path the request was made from with the flow authorizationcode
-
api/github/oauth/authorize
- a route called from the redirect path, served with the flow authorizationcode
, exchanges thecode
for access token and returns it as response.
-
-
I implemented the first
action
(not related to the new and experimental Astro Server Actions, I done this long before the announcement 😏) — this is a term I just made up to call functions that are ran on the server-side of Astro "before the page loads", you shall know it by its naming convension:doAction
, and its style of taking theastroGlobal
object as the only parameter, it's usually async function that returns a response object.-
doAuth
- this action integrates on any page I wish to protect, it checks for the presence of an access token in cookie; — if present: it exchanges that for user data, returns a boolean valueisAuthed
alongside it to confirm authentication for protected page; — if no token is found: it checks the presence of the oath flow authorizationcode
in url search params, exchanges that for access token (by calling theapi/github/oauth/authorize
route) and saves it secure to cookies, then uses the cookie appropriately; now in cases where no accessToken is found in cookies and no authcode
is in url search params, then the returned valueisAuthed
is false and it will be used on the protected page to redirect to the login page.const { isAuthed, authedData: userData } = await doAuth(Astro); if (!isAuthed) return redirect(`/login?return_to=${pathname}`);
- ...this
doAuth
action also returns a utility functiongetAuthUrl
that is used to generate a GitHub OAuth flow url which is in turn added as link to the "Connect with GitHub" on the login page and once clicked, it starts an OAuth flow
-
See PR:
feat: implement auth (with github oauth) #8
This Pull request implement the authentication feature in the project; using the github oauth, our primary goal is to get and hold users github accessToken in cookies for performing specific functionality. It is important to state that this feature does not take store this user's accessToken to any remote server, this token and any other information that was retrieved using the token are all saved securely on the users' end through usage of cookies.
-
Implemented the github oauth callback handler at
/api/github/oauth/callback
- this handler's main functionality is to receive github's authorizationcode
andstate
to perform either of the following operations- Redirect to the path stated in the
state
params with the authorizationcode
concatenated to it using theAstro.context.redirect
method - or If a
redirect=true
value if found in thestate
param, then we redirect to the the path stated in thestate
params with the authorizationcode
andredirect=true
value concatenated to it usingAstro.context.redirect
method
- Redirect to the path stated in the
-
Implemented the github oauth authorization handler at
/api/github/oauth/authorization
- this handler is a helper that primarily exchanges the authorizationcode
fortokens
and returns it in a json object. -
Created a singleton instance of our github
app
atlib/octokit/app
-
Added a new
crypto
util function which providesencrypt
anddecrypt
helper function has exports; it is intended to be used for securing the users relatedcookies
-
Implemented the
doAuth
action function - this function take theAstro
global object as argument and performs the operations stated below/** * Authentication action with GitHub OAuth * @param {import("astro").AstroGlobal} astroGlobal */ export default async function doAuth(astroGlobal) { const { url: { searchParams }, cookies } = astroGlobal; const code = searchParams.get("code"); const accessToken = cookies.get("jargons.dev:token", { decode: value => decrypt(value) }); /** * Generate OAuth Url to start authorization flow * @todo make the `parsedState` data more predictable (order by path, redirect) * @todo improvement: store `state` in cookie for later retrieval in `github/oauth/callback` handler for cleaner url * @param {{ path?: string, redirect?: boolean }} state */ function getAuthUrl(state) { const parsedState = String(Object.keys(state).map(key => key + ":" + state[key]).join("|")); const { url } = app.oauth.getWebFlowAuthorizationUrl({ state: parsedState }); return url; } try { if (!accessToken && code) { const response = await GET(astroGlobal); const responseData = await response.json(); if (responseData.accessToken && responseData.refreshToken) { cookies.set("jargons.dev:token", responseData.accessToken, { expires: resolveCookieExpiryDate(responseData.expiresIn), encode: value => encrypt(value) }); cookies.set("jargons.dev:refresh-token", responseData.refreshToken, { expires: resolveCookieExpiryDate(responseData.refreshTokenExpiresIn), encode: value => encrypt(value) }); } } const userOctokit = await app.oauth.getUserOctokit({ token: accessToken.value }); const { data } = await userOctokit.request("GET /user"); return { getAuthUrl, isAuthed: true, authedData: data } } catch (error) { return { getAuthUrl, isAuthed: false, authedData: null } } }
- it provides (in its returned object) a helper function that can be used to generate a new github oauth url, this helper consumes our github
app
instance and it accepts astate
object withpath and
redirectproperty to build out the
state` value that is held within the oauth url - it sets
cookies
data fortokens
- it does this when it detects the presence of the authorizationcode
in theAstro.url.searchParams
and reads the absense no project relatedaccessToken
in cookie; this assumes that there's a new oauth flow going through it; It performs this operation by first calling the github oauth authorization handler at/api/github/oauth/authorization
where it gets thetokens
data that it adds tocookie
and ensure its securely store by running theencrypt
helper to encode it value - In cases where there's no authorization
code
in theAstro.url.searchParams
and finds a project relatedtoken
incookie
, It fetches users's data and provides it in its returned object for consumptions; it does this by getting the users octokit instance from our githubapp
instance using thegetUserOctokit
method and the user's neccesasrytokens
present in cookie; this users octokit instance is then used to request for user's data which is in turn returned - It also returns a boolean
isAuthed
property that can be used to determine whether a user is authenticated; this property is a statically computed property that only always returns turn when all operation reaches final execution point in thetry
block of thedoAuth
action function and it returns false when anerror
occurs anywhere in the operation to trigger thecatch
block of thedoAuth
action function
- it provides (in its returned object) a helper function that can be used to generate a new github oauth url, this helper consumes our github
-
Added the
login
page which stands as place where where unauthorised users witll be redirected to; this page integrates thedoAuth
action, destruing out thegetAuthUrl
helper and theisAuthed
property, it uses them as followsconst { getAuthUrl, isAuthed } = await doAuth(Astro); if (isAuthed) return redirect(searchParams.get("redirect")); const authUrl = getAuthUrl({ path: searchParams.get("redirect"), redirect: true });
-
isAuthed
- this property is check on the server-side on the page to check if a user is already authenticated from within thedoAuth
and redirects to the value stated the page'sAstro.url.searchParams.get("redirect")
- When a user is not authenticated, it uses the
getAuthUrl
to generate a new github oauth url and imperatively set the argumentstate.redirect
totrue
- Implemented a new
user
store with a Map store value$userData
to store user data to state
-
// pages/sandbox.astro
---
import BaseLayout from "../layouts/base.astro";
import doAuth from "../lib/actions/do-auth.js";
import { $userData } from "../stores/user.js";
const { url: { pathname }, redirect } = Astro;
const { isAuthed, authedData } = await doAuth(Astro);
if (!isAuthed) return redirect(`/login?redirect=${pathname}`);
$userData.set(authedData);
---
<BaseLayout pageTitle="Dictionary">
<main class="flex flex-col max-w-screen-lg p-5 justify-center mx-auto min-h-screen">
<div class="w-fit p-4 ring-2 rounded-full ring-gray-500 m-auto flex items-center space-x-3">
<img class="w-10 h-10 p-1 rounded-full ring-2 ring-gray-500"
src={authedData.avatar_url}
alt={authedData.login}
>
<p>Hello, { authedData.login }</p>
</div>
</main>
</BaseLayout>
Explainer
- We destructure
isAuthed
andauthedData
from thedoAuth
action - Check whether a user is not authenticated? and do a redirect to
login
page stating the currentpathname
as value for theredirect
search param (a data used in state to dictate where to redirect to after authentication complete) if no user is authenticated - or Proceed to consuming the
authedData
which will be available when aisAuthed
istrue
. by setting it to the$userData
map store property
screencast-bpconcjcammlapcogcnnelfmaeghhagj-2024.03.29-20_36_15.webm
- Added new node package https://www.npmjs.com/package/@astrojs/node for SSR adapter intergation
Second Iteration
This iteration implements improvements by making making the parsedState
derived from the getAuthUrl
function call more predictable removing the chances of an error in the api/github/oauth/callback
route; it also renames some terms used in the search params and implements the the encodeURIComponent
to make our redirect urls look less weird
See PR:
feat: implement `auth` (second iteration) improvements #28
This PR implements some improvement to mark the second iteration of the auth
feature in the project. Follow-up to #8
- Addressed "todo make the
parsedState
data more predictable (order by path, redirect)" by implementing more predicatable manner of generating thestate
string for the oauth url; this is done by individually looking for the requiredstate
objectkey
to fill in theparsedState
string in required order. Leaving the newgetAuthUrl
helper function looking like so...
function getAuthUrl(state) {
let parsedState = "";
if (!isObjectEmpty(state)){
if (state.path) parsedState += `path:${state.path}`;
const otherStates = String(Object.keys(state)
.filter(key => key !== "path" && key !== "redirect")
.map(key => key + ":" + state[key]).join("|"));
if (otherStates.length > 0) parsedState += `|${otherStates}`;
}
const { url } = app.oauth.getWebFlowAuthorizationUrl({
state: parsedState
});
return url;
}
- Implemented a new utility function
isObjectEmpty
to check if an object has value or not - Removed usage of
redirect
property in state object; its redundant 😮💨 - Renamed login redirect path params property name to
return_to
fromredirect
for readability reasons - Implemented
encodeURIComponent
in login redirect path params value to stop its part on the url from looking like weird 😃;- Takes the example url from looking like this...
/login?return_to=/editor
to looking like so.../login?return_to=%2Feditor
- Takes the example url from looking like this...
Resolves #15
Third Iteration
This iteration refactors the most parts of the implementation in the "First Iteration" because of a certain limitation that surfaced during the work I was doing on another script.
At this point in time I was working on the "Submit Word" script; this script leverages the GitHub API and create a Pull Request to merge changes made from the currently authenticated user's fork branch to the base (jargons.dev) main branch. This ofcourse is made possible by the access token of the user saved to cookies which is used in the request headers as "Authorization Bearer token" by the SDK i.e. Octokit that facilitates our interaction with the GitHub APIs.
The Limitation
During a test moment as I gave the submit word script a whirl, I was met by the error...
Error: Resource not accessible by integration
...this quickly became a blocker and I consulted @gr2m with whom we quickly uncovered the limitation which related to my integrations of GitHub App.
As initially stated, the GitHub App uses "Permissions" with a fine-grained token - a new token type that GitHub encourages for some very good reasons with the below quoted one as one that concerns us here...
GitHub Apps provide more control over what the app can do. Instead of the broad scopes that OAuth apps use, GitHub Apps use fine-grained permissions. For example, if your app needs to read the contents of a repository, an OAuth app would require the repo
scope
, which would also let the app edit the repository contents and settings. A GitHub App can request read-only access to repository contents, which will not let the app take more privileged actions like editing the repository contents or settings.
...this means that when using "Permissions" (i.e. fine-grained permissions), a user must have write access to the upstream/base repository which in this case is our jargons.dev repository; as stated in the GitHub Create a Pull Request docs.
Say what!? Nope!!!
It was at that point that we found plain old scope
to be exactly what we need; In order to be able to access the required resource, the public_repo
scope was everything.
The Swap for GitHub's OAuth App from GitHub App
In order to move forward, I had to switch from "permissions" to "scope" and where we found that was in the GitHub's "OAuth App"; this was the basis on which the third iteration was patched.
So this iteration mainly focused on exchanging the GitHub OAuth integration, also ensuring that the implemented helpers/functions/api in this iteration resemble the ones that was made available by the GitHub App to reduces the amount of changes I was going to make across the entire codebase in acknowledgement of the new implementation.
The Trade Offs
GitHub App is great, I must acknowledge that I still have it in my mind for the future if we end-up finding a solution to the Error: Resource not accessible by integration
error, but the functionality to create a Pull Request performed by the submit-word
script is an imperative part of the project, so you bet we gotta make sure it works.
It's important to state that there was some trade-offs that I had to settle for in favor of the functionality...
- No More Short-live token - the GitHub App provides an
accessToken
that expires after a certain period and arefreshToken
to refresh this token; this is very good for security; Unlike OAuth App that provides anaccessToken
that never expires at all, and doesn't provide arefreshToken
ofcourse - No More token that works only through jargons.dev - I understood (important to state) that
accessToken
generated via OAuth flow initiated through jargons.dev can only be used to make request through jargons.dev, making it not-possible to take this token to use as authorization elsewhere; Unlike OAuth App that providesaccessToken
that can be grabbed and used any where else like you would use a normal personal access token generated from your GitHub accounts.
The Workarounds
- No More Short-live tokens - I intentionally added an 8 hours expiry for the
accessToken
when saving it to cookie to ensure that it atleast gets deleted from cookie, hence triggering a new OAuth flow to ensure a newaccessToken
(if that truly is the case) is generated from the flow. - No More token that works only through jargons.dev - Haha, it is imperative to state that at the point when we save the
accessToken
to cookie, it get encrypted, which means it is less-likely that the encrypted token is useful anywhere else because they'd need to decrypt something that we have encrypted. So you can say we have placed a lock on the token that only jargons.dev can unlock.
See PR:
refactor(auth): replace `github-app-oauth` with classic `oauth` app #33
This Pull request refactors the authentication system, replacing the usage of github-app-oauth
with classic github oauth
app. This decision was taken because of the limitations discovered using the Pull Request endpoint (implementation in #25); the github-app-oauth
uses permissions
which requires a user to have write
access to the upstream
(i.e. write
access to atleast pull-requests
on our/this project repo) before a pull request can created from their forked repo branch to the main project repo.
This PR goes to implement classis oauth
app, which uses scopes
and allows user access to create the pull request to upstream
repo on the public_repo
scope. The changes made in this PR was done to mimic the normal Octokit.App
's methods/apis as close as possible to allow compatibility with the implementation in #8 and #28 (or for cases when we revert back to using the github-app-oauth
in the future --- maybe we end up finding a solution because honestly I really prefer the github-app-oauth
😉).
It is also important to state that this oauth
app option doesn't offer a short lived token (hence we only have an accessToken
without expiry and No refreshToken
), but I have configured the token to expire out of cookie in 8hours; even though we might be getting exactly thesame token back from github after this expires and we re-authorize the flow, I just kinda like that feeling of the cookies expiring after some hours and asking user to re-auth.
- Initialized a new
app
object that returns few methods and objects-
octokit
- the main octokit instance of theoauth
app/** * OAuth App's Octokit instance */ const octokit = new Octokit({ authStrategy: createOAuthAppAuth, auth: { clientId: import.meta.env.GITHUB_OAUTH_APP_CLIENT_ID, clientSecret: import.meta.env.GITHUB_OAUTH_APP_CLIENT_SECRET }, });
-
oauth
-
getWebFlowAuthorizationUrl
- method that generates the oauth flow url/** * Generate a Web Flow/OAuth authorization url to start an OAuth flow * @param {import("@octokit/oauth-authorization-url").OAuthAppOptions} options * @returns */ function getWebFlowAuthorizationUrl({state, scopes = ["public_repo"], ...options }) { return oauthAuthorizationUrl({ clientId: import.meta.env.GITHUB_OAUTH_APP_CLIENT_ID, state, scopes, ...options }); }
-
exchangeWebFlowCode
- method that exchanges oauth web flow returnedcode
foraccessToken
; this functionality was extracted from thegithub/oauth/authorize
endpoint to have all auth related function packed in one place/** * Exchange Web Flow Authorization `code` for an `access_token` * @param {string} code * @returns {Promise<{access_token: string, scope: string, token_type: string}>} */ async function exchangeWebFlowCode(code) { const queryParams = new URLSearchParams(); queryParams.append("code", code); queryParams.append("client_id", import.meta.env.GITHUB_OAUTH_APP_CLIENT_ID); queryParams.append("client_secret", import.meta.env.GITHUB_OAUTH_APP_CLIENT_SECRET); const response = await fetch("https://github.com/login/oauth/access_token", { method: "POST", body: queryParams }); const responseText = await response.text(); const responseData = new URLSearchParams(responseText); return responseData; }
-
-
getUserOctokit
- method that gets an octokit instance of a user./** * Get a User's Octokit instance * @param {Omit<OctokitOptions, "auth"> & { token: string }} options * @returns {Octokit} */ function getUserOctokit({ token, ...options }) { return new Octokit({ auth: token, ...options }); };
-
- Integrated the
app.oauth.exchangeWebFlowCode
method into thegithub/oauth/authorize
endpoint handler - Removed the
refreshToken
andrefreshTokenExpiresIn
fromgithub/oauth/authorize
endpoint response object. - Modified
doAuth
actions- Removed
jargons.dev:refresh_token
value set to cookie; - Corrected computation of
userOctokit
to useapp.getUserOctokit
fromapp.oauth.getUserOctokit
(even though I could just move thegetUserOctokit
method to theapp.oauth
object in the new implmentation, I just prefer it this way 😉).
- Removed
📖
screencast-bpconcjcammlapcogcnnelfmaeghhagj-2024.04.07-07_37_31.webm
Posted on August 26, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.