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

Capstone: Multi-Provider Login Page

What we are building

This lesson assembles every piece from the course into a single, complete project. You will have a working app with:

  • GitHub login
  • Google login
  • Email/password signup and login
  • Account linking across all three
  • Logout
  • A protected profile page
  • Error handling on every path

Project structure

oauth-course/
  .env
  .gitignore
  package.json
  tsconfig.json
  src/
    app.ts              # setup() with routes, hooks
    server.ts           # starts the server
    env.ts              # environment variable access
    db.ts               # User type, store, find/create functions
    sessions.ts         # session store with expiry
    cookies.ts          # cookie parsing and formatting
    auth.ts             # authenticate function
    oauth-state.ts      # CSRF state management
    github.ts           # GitHub API calls
    routes/
      auth.ts           # POST /signup, POST /login, POST /logout
      github.ts         # GET /auth/github, GET /auth/github/callback
      google.ts         # GET /auth/google, GET /auth/google/callback

The complete app.ts

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

export const app = setup({
  onRequest: ({ request }) => ({
    startTime: Date.now(),
  }),

  routes: [
    route.get("/", {
      resolve: () =>
        new Response(
          `<!DOCTYPE html>
<html>
<head><title>OAuth Course</title></head>
<body>
  <h1>OAuth Course</h1>
  <h2>Social Login</h2>
  <p><a href="/auth/github">Log in with GitHub</a></p>
  <p><a href="/auth/google">Log in with Google</a></p>
  <h2>Email / Password</h2>
  <p>Use <code>POST /signup</code> with <code>{"email", "password"}</code></p>
  <p>Use <code>POST /login</code> with <code>{"email", "password"}</code></p>
  <hr>
  <p><a href="/me">View profile</a></p>
</body>
</html>`,
          { 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,
          providers: {
            github: user.githubId !== null,
            google: user.googleId !== null,
            password: user.passwordHash !== null,
          },
        });
      },
    }),

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

    ...authRoutes,
    ...githubRoutes,
    ...googleRoutes,
  ],

  onResponse: ({ request, response, locals }) => {
    const duration = Date.now() - locals.startTime;
    const url = new URL(request.url);
    console.log(`${request.method} ${url.pathname} ${response.status} ${duration}ms`);
    return response;
  },

  onError: ({ error }) => {
    console.error("Unhandled error:", error);
    return Response.json({ error: "Internal error" }, { status: 500 });
  },
});

The /me route now includes a providers object showing which auth methods are linked. This lets a frontend display “Connected: GitHub, Google” or “Add a password” prompts.

The complete User type

// src/db.ts
export interface User {
  id: string;
  email: string | null;
  name: string | null;
  avatarUrl: string | null;
  passwordHash: string | null;
  githubId: number | null;
  googleId: string | null;
}

Seven fields. Three of them are auth-related (passwordHash, githubId, googleId). All three are nullable because a user might only have one or two linked.

The complete env.ts

// src/env.ts
import "dotenv/config";

function required(name: string): string {
  const value = process.env[name];
  if (!value) {
    throw new Error(`Missing required environment variable: ${name}`);
  }
  return value;
}

export const env = {
  githubClientId: required("GITHUB_CLIENT_ID"),
  githubClientSecret: required("GITHUB_CLIENT_SECRET"),
  googleClientId: required("GOOGLE_CLIENT_ID"),
  googleClientSecret: required("GOOGLE_CLIENT_SECRET"),
  baseUrl: required("BASE_URL"),
};

Five variables. All required at startup. The server does not start if any are missing.

Test the full flow

Start the server:

npm run dev

GitHub login

# Open in a browser:
# http://localhost:3000
# Click "Log in with GitHub"
# Authorize on GitHub
# You are redirected back, logged in

# Verify:
curl -b cookies.txt http://localhost:3000/me

Google login (same email)

# Clear cookies or open an incognito window
# http://localhost:3000
# Click "Log in with Google"
# Choose a Google account with the same email as your GitHub
# You are redirected back, logged in to the SAME account

# Verify both providers are linked:
curl -b cookies.txt http://localhost:3000/me
# { "providers": { "github": true, "google": true, "password": false } }

Password signup (different user)

curl -c cookies.txt -X POST http://localhost:3000/signup \
  -H "Content-Type: application/json" \
  -d '{"email": "[email protected]", "password": "password123"}'

curl -b cookies.txt http://localhost:3000/me
# { "providers": { "github": false, "google": false, "password": true } }

Password login

curl -c cookies.txt -X POST http://localhost:3000/login \
  -H "Content-Type: application/json" \
  -d '{"email": "[email protected]", "password": "password123"}'

curl -b cookies.txt http://localhost:3000/me

Logout

curl -b cookies.txt -c cookies.txt -X POST http://localhost:3000/logout

curl -b cookies.txt http://localhost:3000/me
# { "error": "Unauthorized" }

What you built

ConceptWhere it appears
OAuth authorization code flowGitHub and Google redirect/callback routes
State parameter (CSRF)oauth-state.ts, verified in both callbacks
Token exchangeServer-to-server POST in both callbacks
Provider API callsgithub.ts (profile + emails)
OpenID Connect ID tokensGoogle callback decodes JWT
Account linkingfindOrCreateFromGithub/Google link by verified email
Password authSignup/login routes with bcrypt
Session managementsessions.ts, cookies.ts, same as password auth
Environment variables.env file, env.ts with required()
Error handlingState errors, consent denied, code expired, API failures

All of it is plain functions and standard fetch calls. No auth libraries, no middleware, no magic. Every OAuth HTTP request is visible in the code.

What you understand now

If you worked through this course, you understand the OAuth 2.0 authorization code flow at the HTTP level. You know what every redirect does, where the access token comes from, why the state parameter exists, and how to turn a provider profile into a local user account.

When you encounter auth libraries (Passport.js, NextAuth, Auth.js, Lucia), you will recognize what they do internally. You can evaluate whether they fit your needs because you understand the protocol they wrap.

Where to go from here

Add more providers. The pattern repeats: register an app, build a redirect route, build a callback handler, map the profile to your user model. Try Discord, Apple, or Microsoft.

Refresh tokens. Some providers issue refresh tokens that let you get new access tokens without re-prompting the user. This is useful if you need long-lived API access (e.g. reading a user’s repos periodically).

PKCE (Proof Key for Code Exchange). An extension to the authorization code flow that adds security for public clients (SPAs, mobile apps). Google and GitHub both support it.

OpenID Connect discovery. Instead of hardcoding provider URLs, use the provider’s .well-known/openid-configuration endpoint to discover them automatically.

Database storage. Replace the in-memory Map with a real database. The auth functions stay the same; only the data access layer changes.

Challenges

Challenge 1: Add a “connected accounts” page. Build a GET /me/connections route that shows which providers are linked and which are not. For unlinked providers, show a “Connect” link that starts the OAuth flow. After linking, the user should be redirected back to the connections page.

Challenge 2: Add account unlinking. Build a POST /me/disconnect/github route that sets githubId to null. Prevent the user from unlinking their last auth method (if they have no password and only one provider, disconnecting it would lock them out).

Challenge 3: Add a third provider. Pick any OAuth provider (Discord, Twitter, Apple) and add it. You will need to register an app, find their authorization and token endpoints, and map their profile format to your User type. The flow is the same.

A user has GitHub and Google linked. They visit /me. Which of these correctly describes the response?

What is the single most important security measure in the entire OAuth flow?

← Common Mistakes Back to course →

© 2026 hectoday. All rights reserved.