Project Setup
A fresh project
This course uses a standalone project. You do not need to have completed the “Authentication with Hectoday HTTP” course, but if you did, the patterns will be familiar: sessions, cookies, and the authenticate() function all work the same way.
Create the project
mkdir oauth-course
cd oauth-course
npm init -y Install dependencies
npm install @hectoday/http zod srvx
npm install -D typescript @types/node tsx These are the same tools from the auth course. Hectoday HTTP for the framework, Zod for validation, srvx to run on Node.js, tsx to run TypeScript directly.
We do not need bcrypt or jose. OAuth replaces password verification with provider-based auth, and we will use sessions (not JWTs) for ongoing identity.
Configure TypeScript
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"rootDir": "./src",
"outDir": "dist",
"types": ["node"]
},
"include": ["src"]
} Environment variables
OAuth requires secrets (the client secret from GitHub and Google). These must not be committed to source code. We will use a .env file for development and process.env for production.
Install a .env loader:
npm install dotenv Create .env in the project root:
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
BASE_URL=http://localhost:3000 Leave the GitHub and Google values empty for now. We will fill them in when we register the OAuth apps.
[!WARNING] Add
.envto your.gitignoreimmediately. This file contains secrets that must never be committed.
Create .gitignore:
node_modules
dist
.env Load environment variables
Create src/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"),
baseUrl: required("BASE_URL"),
}; import "dotenv/config" loads the .env file into process.env as a side effect. The required function ensures the variable exists and throws a clear error at startup if it does not.
We start with just the GitHub variables. We will add Google later.
[!NOTE] The
envobject is imported wherever secrets are needed. This keeps environment variable access in one place and makes it obvious what configuration the app requires.
Session and cookie helpers
We need session management. These are the same helpers from the auth course, included here so this project is self-contained.
Create src/sessions.ts:
// src/sessions.ts
export interface Session {
userId: string;
createdAt: number;
}
const store = new Map<string, Session>();
export function createSession(userId: string): string {
const id = crypto.randomUUID();
store.set(id, { userId, createdAt: Date.now() });
return id;
}
export function getSession(id: string): Session | undefined {
const session = store.get(id);
if (!session) return undefined;
const maxAge = 24 * 60 * 60 * 1000;
if (Date.now() - session.createdAt > maxAge) {
store.delete(id);
return undefined;
}
return session;
}
export function deleteSession(id: string): void {
store.delete(id);
} Create src/cookies.ts:
// src/cookies.ts
const COOKIE_NAME = "session";
export function parseCookies(header: string | null): Record<string, string> {
if (!header) return {};
const cookies: Record<string, string> = {};
for (const pair of header.split(";")) {
const [name, ...rest] = pair.trim().split("=");
if (name) {
cookies[name] = rest.join("=");
}
}
return cookies;
}
export function getSessionId(request: Request): string | undefined {
const cookies = parseCookies(request.headers.get("cookie"));
return cookies[COOKIE_NAME];
}
export function sessionCookie(sessionId: string): string {
return `${COOKIE_NAME}=${sessionId}; HttpOnly; SameSite=Lax; Path=/; Max-Age=86400`;
}
export function clearSessionCookie(): string {
return `${COOKIE_NAME}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`;
} User store
Create src/db.ts:
// src/db.ts
export interface User {
id: string;
email: string | null;
name: string | null;
avatarUrl: string | null;
githubId: number | null;
googleId: string | null;
}
export const users = new Map<string, User>();
export function findByGithubId(githubId: number): User | undefined {
return Array.from(users.values()).find((u) => u.githubId === githubId);
}
export function findByGoogleId(googleId: string): User | undefined {
return Array.from(users.values()).find((u) => u.googleId === googleId);
}
export function findByEmail(email: string): User | undefined {
return Array.from(users.values()).find((u) => u.email === email);
} Notice this User type is different from the auth course. There is no passwordHash or role field. Instead, there are githubId and googleId fields for linking OAuth accounts. Both are nullable because a user might only have one provider linked.
The lookup functions search by provider ID or email. These are linear scans over the Map, which is fine for learning. In production with a database, these would be indexed queries.
The app shell
Create src/app.ts:
// src/app.ts
import { setup, route } from "@hectoday/http";
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("/health", {
resolve: () => Response.json({ status: "ok" }),
}),
],
}); A minimal HTML home page with a “Log in with GitHub” link. We will add routes as we build them.
Create src/server.ts:
// src/server.ts
import { serve } from "srvx";
import { app } from "./app.js";
serve({ fetch: app.fetch, port: 3000 }); The dotenv/config import is in env.ts, which gets imported when the app loads its routes. We do not need it in server.ts too.
Add scripts to package.json:
{
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts"
}
} Verify it works
The server will fail to start because the GitHub environment variables are empty. That is expected. We will fill them in the next lesson when we register the GitHub OAuth App.
For now, temporarily comment out the required calls in src/env.ts (or set dummy values) and verify the basics:
npm run dev Visit http://localhost:3000. You should see the “Log in with GitHub” link. The link goes to /auth/github, which will 404 for now. That is the route we build in the next section.
File layout
oauth-course/
.env
.gitignore
package.json
tsconfig.json
src/
app.ts # routes, hooks
server.ts # starts the server
env.ts # environment variable access
db.ts # User type, in-memory store
sessions.ts # session store
cookies.ts # cookie helpers Why do we use a .env file instead of hardcoding secrets in the source code?
Why does the User type have nullable githubId and googleId fields instead of a single 'provider' field?