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:
- Create a session with the user’s local ID
- Set the session cookie
- 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
- Visit
http://localhost:3000 - Click “Log in with GitHub”
- Authorize the app on GitHub
- You should be redirected back to
/ - 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:
- Your server redirected you to GitHub
- You authorized the app on GitHub
- GitHub redirected you back with a code
- Your server exchanged the code for an access token (server-to-server)
- Your server fetched your profile from GitHub’s API (server-to-server)
- Your server created a local user account
- Your server created a session and set a cookie
- 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?