Sign in with GitHub

cyco130

Fatih Aygün

Posted on August 13, 2022

Sign in with GitHub

GitHub offers a free API for authenticating users. It's based on OAuth, an open standard for authentication. OAuth is a fairly large subject but our use case is not that complicated. You can learn more on GitHub's documentation but this is how it works in essence:

  1. We'll create a GitHub app and enter a callback URL there. We'll receive a client ID and a client secret (which are just random-looking strings).
  2. We'll add a "Sign in with GitHub" link to our page. The link will point to a GitHub URL that will include our client ID and a random string that we'll generate (called "state") in query parameters.
  3. GitHub will show the user a page depending on their authentication status:
    • The user will be shown the GitHub sign-in page (only if they are not already signed in).
    • The user will be asked whether they want to authorize our app (only if they have not already authorized our app recently).
    • If they accept (or had accepted recently), GitHub will redirect the user to the callback URL we defined in step 1.
  4. The redirection will include a code and the state we sent in step 2 as query parameters. If the state doesn't match the random string that we sent, we'll know something fishy is going on and abort the process. Otherwise, we'll send a POST request to https://github.com/login/oauth/access_token along with our client ID, client secret, and the code we received as a query parameter. If everything goes well, GitHub will reply with an access token.
  5. We'll use the access token in the Authorization header every time we want to get the user's profile data from GitHub.

We have a plan. Let's start.

Create a GitHub app

Head over to GitHub Developer Settings, click OAuth Apps on the left and then click the "New OAuth app" button. It's gonna ask you a few questions. Enter http://localhost:5173 for the homepage URL and http://localhost:5173/login for the callback URL, and fill the rest as you like. We're giving localhost addresses because we have to test our app before deploying to its final URL. You can just update the URLs when you deploy or create a new app and keep this one for testing and development.

After you submit the form, you'll end up on a page where you will see your app's client ID. You will also see a "Generate a new client secret" button. Go ahead and generate one and copy both into a new file in your repository's root directory and save it with the name .env. It should look like this:

GITHUB_CLIENT_ID=<your client ID>
GITHUB_CLIENT_SECRET=<your client secret>
Enter fullscreen mode Exit fullscreen mode

It's good practice to keep our app secrets and configuration in environment variables. Now add this file to your .gitignore file so you don't accidentally push your secret to GitHub. To load this into the environment during development, we'll install the dotenv package:

npm install -D dotenv
Enter fullscreen mode Exit fullscreen mode

Then we'll import it and call it in our vite.config.ts. The file will end up looking like this:

import { defineConfig } from "vite";
import rakkas from "rakkasjs/vite-plugin";
import tsconfigPaths from "vite-tsconfig-paths";

dotenv.config();

export default defineConfig({
    envDir: ".",
    plugins: [
        tsconfigPaths(),
        rakkas({
            adapter: "cloudflare-workers",
        }),
    ],
});
Enter fullscreen mode Exit fullscreen mode

Now we will be able to access the variables with, e.g., process.env.GITHUB_CLIENT_ID in our server-side code. process.env is a Node-specific global but Rakkas makes it available on Cloudflare Workers too.

Adding a "sign in" link

Right now, we only have a single page. But it's not gonna be like that forever. We probably want to see the "sign in" link on the header of every page. Rakkas has a layout system for shared elements like this. Layouts wrap nested layouts and pages under the same directory and its subdirectories. So if we create a layout.tsx file in the src/routes directory, it will wrap every single page on our app.

We said the "sign in" link would point to a GitHub URL. That URL, according to GitHub's documentation, is https://github.com/login/oauth/authorize?client_id=<CLIENT_ID>${clientId}&state=<STATE>. Our client ID is in process.env.GITHUB_CLIENT_ID which is only accessible on the server-side. So we'll use useServerSideQuery again to access it. We'll tackle the handling of the state parameter later, let's give it 12345 for now. So here's the first draft of our src/routes/layout.tsx:

import { LayoutProps, useServerSideQuery } from "rakkasjs";

export default function MainLayout({ children }: LayoutProps) {
    const {
        data: { clientId, state },
    } = useServerSideQuery(() => ({
        clientId: process.env.GITHUB_CLIENT_ID,
        state: "12345",
    }));

    return (
        <>
            <header>
                <strong>uBlog</strong>
                <a
                    style={{ float: "right" }}
                    href={
                        "https://github.com/login/oauth/authorize" +
                        `?client_id=${clientId}` +
                        `&state=${state}`
                    }
                >
                    Sign in with GitGub
                </a>
                <hr />
            </header>
            {children}
        </>
    );
}
Enter fullscreen mode Exit fullscreen mode

When you launch the dev server you'll see that we have a site header now. And the "Sign in with GitHub" link will take you to GitHub's authorization page. If you go ahead and authorize your app GitHub will redirect you to a URL that looks like http://localhost:5173/login?code=<BUNCH_OF_RANDOM_LOOKING_CHARS>&state=12345. http://localhost:5173/login is the URL we entered as the callback URL and the rest are the parameters sent by GitHub. Of course, you will get a 404 error because we haven't implemented that endpoint yet. Let's do that now.

Login callback

We'll create a src/routes/login.page.tsx file to implement the login callback. In it, we will use the code query parameter to get an access token from GitHub and then use that access token to get the user's profile data. We will use the useServerSideQuery hook again because we don't want to expose our client secret to the client. Remember, the useServerSideQuery callback runs on the server and will not be part of the client bundle. Let's see what the profile data looks like first by printing it as JSON:

import { PageProps, useServerSideQuery } from "rakkasjs";

export default function LoginPage({ url }: PageProps) {
    const error = url.searchParams.get("error");
    const code = url.searchParams.get("code");
    const state = url.searchParams.get("state");

    const { data: userData } = useServerSideQuery(async () => {
        if (code && state === "12345") {
            const { access_token: token } = await fetch(
                "https://github.com/login/oauth/access_token" +
                    `?client_id=${process.env.GITHUB_CLIENT_ID}` +
                    `&client_secret=${process.env.GITHUB_CLIENT_SECRET}` +
                    `&code=${code}`,
                {
                    method: "POST",
                    headers: { Accept: "application/json" },
                }
            ).then((r) => r.json<{ access_token: string }>());

            if (token) {
                const userData = fetch("https://api.github.com/user", {
                    headers: {
                        Authorization: `token ${token}`,
                    },
                }).then((r) => r.json());

                return userData;
            }
        }
    });

    if (error) {
        return <div>Error: {error}</div>;
    }

    return <pre>{JSON.stringify(userData, null, 2)}</pre>;
}
Enter fullscreen mode Exit fullscreen mode

If everything goes well, you should see your GitHub user profile data in JSON format when you click on "Sign in with GitHub". Mine looks like this:

{
    "login": "cyco130",
    "id": 10846005,
    "node_id": "MDQ6VXNlcjEwODQ2MDA1",
    "avatar_url": "https://avatars.githubusercontent.com/u/10846005?v=4",
    "gravatar_id": "",
    "url": "https://api.github.com/users/cyco130",
    "html_url": "https://github.com/cyco130",
    "followers_url": "https://api.github.com/users/cyco130/followers",
    "following_url": "https://api.github.com/users/cyco130/following{/other_user}",
    "gists_url": "https://api.github.com/users/cyco130/gists{/gist_id}",
    "starred_url": "https://api.github.com/users/cyco130/starred{/owner}{/repo}",
    "subscriptions_url": "https://api.github.com/users/cyco130/subscriptions",
    "organizations_url": "https://api.github.com/users/cyco130/orgs",
    "repos_url": "https://api.github.com/users/cyco130/repos",
    "events_url": "https://api.github.com/users/cyco130/events{/privacy}",
    "received_events_url": "https://api.github.com/users/cyco130/received_events",
    "type": "User",
    "site_admin": false,
    "name": "Fatih Aygün",
    "company": "Lityum AŞ",
    "blog": "",
    "location": "Istanbul",
    "email": null,
    "hireable": null,
    "bio": "Programmer, musician, amateur linguist.",
    "twitter_username": "cyco130",
    "public_repos": 32,
    "public_gists": 4,
    "followers": 26,
    "following": 25,
    "created_at": "2015-02-04T09:24:28Z",
    "updated_at": "2022-06-29T03:02:45Z"
}
Enter fullscreen mode Exit fullscreen mode

Success! We've accomplished quite a lot! It's a good time to take a break!

What's next?

In the next article, we'll finish our authentication feature. We will use cookies to remember who is who.

You can find the progress up to this point on GitHub.

💖 💪 🙅 🚩
cyco130
Fatih Aygün

Posted on August 13, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related