hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses OAuth and Social Login

Why OAuth?

  • The Problem with Passwords
  • OAuth 2.0 in Plain English
  • The Authorization Code Flow, Step by Step
  • Project Setup

GitHub Login

  • Register a GitHub OAuth App
  • The Authorization Redirect
  • The State Parameter
  • The Callback Handler
  • Fetching the User Profile
  • Creating or Linking Accounts
  • The Complete Flow

Google Login

  • Register a Google OAuth App
  • Building Google Login

Production Concerns

  • Multiple Providers, One User
  • Combining OAuth with Password Auth
  • Error Handling
  • Logout and Token Cleanup
  • Common Mistakes
  • Capstone: Multi-Provider Login Page

Creating or Linking Accounts

The three cases

When a user completes the GitHub OAuth flow, your server has their GitHub ID, name, email, and avatar. Now you need to decide: does this user already exist in your system?

There are three cases:

Case 1: Returning user. A user with this GitHub ID already exists. They have logged in with GitHub before. Find their local account and start a session.

Case 2: First-time user. No user has this GitHub ID. This is a new user. Create a local account from the GitHub profile and start a session.

Case 3: Email conflict. No user has this GitHub ID, but a user with the same email already exists (perhaps they signed up with a different provider or with a password). We will handle this case in Section 3. For now, we treat it as an error.

The findOrCreate function

Add to src/db.ts:

// src/db.ts

// ... existing code (User interface, users Map, find functions) ...

export function findOrCreateFromGithub(profile: {
  githubId: number;
  email: string | null;
  name: string | null;
  avatarUrl: string | null;
}): { user: User; created: boolean } {
  // Case 1: Returning user
  const existing = findByGithubId(profile.githubId);
  if (existing) {
    return { user: existing, created: false };
  }

  // Case 3: Email conflict (handle properly in Section 3)
  if (profile.email) {
    const emailConflict = findByEmail(profile.email);
    if (emailConflict) {
      throw new Error(
        `An account with email ${profile.email} already exists. Account linking is covered in a later lesson.`,
      );
    }
  }

  // Case 2: New user
  const user: User = {
    id: crypto.randomUUID(),
    email: profile.email,
    name: profile.name,
    avatarUrl: profile.avatarUrl,
    githubId: profile.githubId,
    googleId: null,
  };

  users.set(user.id, user);
  return { user, created: true };
}

The function returns both the user and a created boolean. This lets the caller know if the user is new (you might want to show a welcome screen) or returning.

The email conflict case throws an error for now. In Section 3, we will replace this with proper account linking.

Complete the callback handler

Now we can finish the callback. Update the end of the callback handler in src/routes/github.ts:

const [githubUser, githubEmails] = await Promise.all([
  fetchGitHubUser(accessToken),
  fetchGitHubEmails(accessToken),
]);

const email = githubUser.email ?? getPrimaryEmail(githubEmails);

// Create or find the local user
let result;
try {
  result = findOrCreateFromGithub({
    githubId: githubUser.id,
    email,
    name: githubUser.name,
    avatarUrl: githubUser.avatar_url,
  });
} catch (err) {
  return Response.json({ error: (err as Error).message }, { status: 409 });
}

// Create a session
const sessionId = createSession(result.user.id);

// Redirect to the app
return new Response(null, {
  status: 302,
  headers: {
    location: "/",
    "set-cookie": sessionCookie(sessionId),
  },
});

Add the imports:

import { findOrCreateFromGithub } from "../db.js";
import { createSession } from "../sessions.js";
import { sessionCookie } from "../cookies.js";

After creating or finding the user, we:

  1. Create a session with the user’s local ID
  2. Set the session cookie
  3. Redirect the user to the home page

The user is now logged in. From this point on, their browser sends the session cookie with every request, just like password-based auth.

Add a protected route

Let’s add a simple profile page so we can verify the login worked. Add an auth helper and a route.

Create src/auth.ts:

// src/auth.ts
import { users, type User } from "./db.js";
import { getSessionId } from "./cookies.js";
import { getSession } from "./sessions.js";

export function authenticate(request: Request): User | Response {
  const sessionId = getSessionId(request);
  if (!sessionId) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  const session = getSession(sessionId);
  if (!session) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  const user = users.get(session.userId);
  if (!user) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  return user;
}

Update src/app.ts to add a /me route:

// src/app.ts
import { setup, route } from "@hectoday/http";
import { githubRoutes } from "./routes/github.js";
import { authenticate } from "./auth.js";

export const app = setup({
  routes: [
    route.get("/", {
      resolve: () =>
        new Response(`<h1>OAuth Course</h1><p><a href="/auth/github">Log in with GitHub</a></p>`, {
          headers: { "content-type": "text/html" },
        }),
    }),

    route.get("/me", {
      resolve: (c) => {
        const user = authenticate(c.request);
        if (user instanceof Response) return user;

        return Response.json({
          id: user.id,
          name: user.name,
          email: user.email,
          avatarUrl: user.avatarUrl,
          githubId: user.githubId,
        });
      },
    }),

    route.get("/health", {
      resolve: () => Response.json({ status: "ok" }),
    }),

    ...githubRoutes,
  ],
});

Try the full flow

  1. Visit http://localhost:3000
  2. Click “Log in with GitHub”
  3. Authorize the app on GitHub
  4. You should be redirected back to /
  5. Visit http://localhost:3000/me

You should see your profile data. The login worked end to end.

Try visiting /me in a private/incognito window (no session cookie). You should get {"error":"Unauthorized"}.

What just happened

From clicking a link to being logged in:

  1. Your server redirected you to GitHub
  2. You authorized the app on GitHub
  3. GitHub redirected you back with a code
  4. Your server exchanged the code for an access token (server-to-server)
  5. Your server fetched your profile from GitHub’s API (server-to-server)
  6. Your server created a local user account
  7. Your server created a session and set a cookie
  8. Your server redirected you to the home page

Steps 4, 5, 6, and 7 all happened in the callback handler. The user saw a brief redirect and landed on the home page, logged in.

Exercises

Exercise 1: Log in with GitHub, then visit /me. Log out (we have not built logout yet, so clear cookies manually or open a new incognito window). Log in again. Verify that /me returns the same user ID both times. This confirms the “returning user” path works: your server found the existing user by GitHub ID instead of creating a duplicate.

Exercise 2: Add a logout route (POST /logout) using the same pattern from the auth course: delete the session, clear the cookie. Test the full cycle: login, view profile, logout, verify /me returns 401.

Why do we create a local user account instead of just storing the GitHub access token?

← Fetching the User Profile The Complete Flow →

© 2026 hectoday. All rights reserved.