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
| Concept | Where it appears |
|---|---|
| OAuth authorization code flow | GitHub and Google redirect/callback routes |
| State parameter (CSRF) | oauth-state.ts, verified in both callbacks |
| Token exchange | Server-to-server POST in both callbacks |
| Provider API calls | github.ts (profile + emails) |
| OpenID Connect ID tokens | Google callback decodes JWT |
| Account linking | findOrCreateFromGithub/Google link by verified email |
| Password auth | Signup/login routes with bcrypt |
| Session management | sessions.ts, cookies.ts, same as password auth |
| Environment variables | .env file, env.ts with required() |
| Error handling | State 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?