Skip to content

Commit dc2bb1a

Browse files
authored
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. ### Changes Made - Implemented the github oauth callback handler at `/api/github/oauth/callback` - this handler's main functionality is to receive github's authorization `code` and `state` to perform either of the following operations - Redirect to the path stated in the `state` params with the authorization `code` concatenated to it using the `Astro.context.redirect` method - or If a `redirect=true` value if found in the `state` param, then we redirect to the the path stated in the `state` params with the authorization `code` and `redirect=true` value concatenated to it using `Astro.context.redirect` method - Implemented the github oauth authorization handler at `/api/github/oauth/authorization` - this handler is a helper that primarily exchanges the authorization `code` for `tokens` and returns it in a json object. - Created a singleton instance of our github `app` at `lib/octokit/app` - Added a new `crypto` util function which provides `encrypt` and `decrypt` helper function has exports; it is intended to be used for securing the users related `cookies` - Implemented the `doAuth` action function - this function take the `Astro` global object as argument and performs the operations stated below ```js /** * 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 a `state` object with `path and `redirect` property to build out the `state` value that is held within the oauth url - it sets `cookies` data for `tokens` - it does this when it detects the presence of the authorization `code` in the `Astro.url.searchParams` and reads the absense no project related `accessToken` 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 the `tokens` data that it adds to `cookie` and ensure its securely store by running the `encrypt` helper to encode it value - In cases where there's no authorization `code` in the `Astro.url.searchParams` and finds a project related `token` in `cookie`, 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 github `app` instance using the `getUserOctokit` method and the user's neccesasry `tokens` 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 the `try` block of the `doAuth` action function and it returns false when an `error` occurs anywhere in the operation to trigger the `catch` block of the `doAuth` action function - Added the `login` page which stands as place where where unauthorised users witll be redirected to; this page integrates the `doAuth` action, destruing out the `getAuthUrl` helper and the `isAuthed` property, it uses them as follows ```js const { 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 the `doAuth` and redirects to the value stated the page's `Astro.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 argument `state.redirect` to `true` - Implemented a new `user` store with a Map store value `$userData` to store user data to state ### Integration Demo: Protect `/sandbox` page ```js // 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` and `authedData` from the `doAuth` action - Check whether a user is not authenticated? and do a redirect to `login` page stating the current `pathname` as value for the `redirect` 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 a `isAuthed` is `true`. by setting it to the `$userData` map store property ### Screencast/Screenshot [screencast-bpconcjcammlapcogcnnelfmaeghhagj-2024.03.29-20_36_15.webm](https://github.com/babblebey/jargons.dev/assets/25631971/4063a038-bcff-4e19-91ba-6561c8880410) ### Note - Added new node package https://www.npmjs.com/package/@astrojs/node for SSR adapter intergation
1 parent a6dd418 commit dc2bb1a

12 files changed

Lines changed: 407 additions & 1 deletion

File tree

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Create a GitHub App at https://github.com/settings/apps/new
2+
GITHUB_APP_ID=
3+
GITHUB_APP_CLIENT_ID=""
4+
GITHUB_APP_CLIENT_SECRET=""
5+
GITHUB_APP_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMII...und==\n-----END PRIVATE KEY-----\n"
6+
7+
CRYPTO_SECRET_KEY="secret"

astro.config.mjs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@ import { defineConfig } from "astro/config";
22
import tailwind from "@astrojs/tailwind";
33
import mdx from "@astrojs/mdx";
44
import react from "@astrojs/react";
5+
import node from "@astrojs/node";
56

67
// https://astro.build/config
78
export default defineConfig({
8-
integrations: [tailwind(), mdx(), react()]
9+
integrations: [tailwind(), mdx(), react()],
10+
output: "server",
11+
adapter: node({
12+
mode: "standalone"
13+
})
914
});

package-lock.json

Lines changed: 171 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"license": "ISC",
1616
"dependencies": {
1717
"@astrojs/mdx": "^2.2.1",
18+
"@astrojs/node": "^8.2.5",
1819
"@astrojs/react": "^3.1.0",
1920
"@astrojs/tailwind": "^5.1.0",
2021
"@fontsource/ibm-plex-mono": "^5.0.12",

src/lib/actions/do-auth.js

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import app from "../../lib/octokit/app.js";
2+
import { decrypt, encrypt } from "../utils/crypto.js";
3+
import { GET } from "../../pages/api/github/oauth/authorize.js";
4+
import { resolveCookieExpiryDate } from "../../lib/utils/index.js";
5+
6+
/**
7+
* Authentication action with GitHub OAuth
8+
* @param {import("astro").AstroGlobal} astroGlobal
9+
*/
10+
export default async function doAuth(astroGlobal) {
11+
const { url: { searchParams }, cookies } = astroGlobal;
12+
const code = searchParams.get("code");
13+
const accessToken = cookies.get("jargons.dev:token", {
14+
decode: value => decrypt(value)
15+
});
16+
17+
/**
18+
* Generate OAuth Url to start authorization flow
19+
* @todo make the `parsedState` data more predictable (order by path, redirect)
20+
* @todo improvement: store `state` in cookie for later retrieval in `github/oauth/callback` handler for cleaner url
21+
* @param {{ path?: string, redirect?: boolean }} state
22+
*/
23+
function getAuthUrl(state) {
24+
const parsedState = String(Object.keys(state).map(key => key + ":" + state[key]).join("|"));
25+
const { url } = app.oauth.getWebFlowAuthorizationUrl({
26+
state: parsedState
27+
});
28+
return url;
29+
}
30+
31+
try {
32+
if (!accessToken && code) {
33+
const response = await GET(astroGlobal);
34+
const responseData = await response.json();
35+
36+
if (responseData.accessToken && responseData.refreshToken) {
37+
cookies.set("jargons.dev:token", responseData.accessToken, {
38+
expires: resolveCookieExpiryDate(responseData.expiresIn),
39+
encode: value => encrypt(value)
40+
});
41+
cookies.set("jargons.dev:refresh-token", responseData.refreshToken, {
42+
expires: resolveCookieExpiryDate(responseData.refreshTokenExpiresIn),
43+
encode: value => encrypt(value)
44+
});
45+
}
46+
}
47+
48+
const userOctokit = await app.oauth.getUserOctokit({ token: accessToken.value });
49+
const { data } = await userOctokit.request("GET /user");
50+
51+
return {
52+
getAuthUrl,
53+
isAuthed: true,
54+
authedData: data
55+
}
56+
} catch (error) {
57+
return {
58+
getAuthUrl,
59+
isAuthed: false,
60+
authedData: null
61+
}
62+
}
63+
}

src/lib/octokit/app.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { App } from "octokit";
2+
3+
const app = new App({
4+
appId: import.meta.env.GITHUB_APP_ID,
5+
privateKey: import.meta.env.GITHUB_APP_PRIVATE_KEY,
6+
oauth: {
7+
clientId: import.meta.env.GITHUB_APP_CLIENT_ID,
8+
clientSecret: import.meta.env.GITHUB_APP_CLIENT_SECRET
9+
}
10+
});
11+
12+
export default app;

0 commit comments

Comments
 (0)