Combining OAuth with Password Auth
Why offer both
Some users prefer social login for convenience. Others prefer passwords because they do not want to depend on a third party, or their employer restricts which OAuth providers they can use.
Offering both gives users the choice. The same local user account can be accessed via password, GitHub, Google, or any combination.
The merged User type
Our User type needs a passwordHash field alongside the provider IDs. Update the User interface in src/db.ts:
export interface User {
id: string;
email: string | null;
name: string | null;
avatarUrl: string | null;
passwordHash: string | null; // null if the user only uses OAuth
githubId: number | null;
googleId: string | null;
} You will also need to update findOrCreateFromGithub and findOrCreateFromGoogle to include passwordHash: null when creating new users, so the new field is always present.
All three auth fields (passwordHash, githubId, googleId) are nullable. A user might have:
- Only a password (signed up with email/password)
- Only GitHub (signed up with GitHub)
- Password and Google (signed up with password, later linked Google)
- All three
Signup with password
Add bcrypt and create a signup route:
npm install bcryptjs
npm install -D @types/bcryptjs // In src/routes/auth.ts
import bcrypt from "bcryptjs";
import * as z from "zod/v4";
import { route, group } from "@hectoday/http";
import { users, findByEmail } from "../db.js";
import { createSession } from "../sessions.js";
import { sessionCookie, getSessionId, clearSessionCookie } from "../cookies.js";
import { deleteSession } from "../sessions.js";
const SignupBody = z.object({
email: z.email(),
password: z.string().min(8, "Password must be at least 8 characters"),
name: z.string().min(1).optional(),
});
const LoginBody = z.object({
email: z.email(),
password: z.string().min(1),
});
export const authRoutes = group([
route.post("/signup", {
request: { body: SignupBody },
resolve: async (c) => {
if (!c.input.ok) {
return Response.json({ error: c.input.issues }, { status: 400 });
}
const { email, password, name } = c.input.body;
if (findByEmail(email)) {
return Response.json({ error: "Email already registered" }, { status: 409 });
}
const passwordHash = await bcrypt.hash(password, 10);
const user = {
id: crypto.randomUUID(),
email,
name: name ?? null,
avatarUrl: null,
passwordHash,
githubId: null,
googleId: null,
};
users.set(user.id, user);
const sessionId = createSession(user.id);
return Response.json(
{ id: user.id, email: user.email },
{
status: 201,
headers: { "set-cookie": sessionCookie(sessionId) },
},
);
},
}),
route.post("/login", {
request: { body: LoginBody },
resolve: async (c) => {
if (!c.input.ok) {
return Response.json({ error: c.input.issues }, { status: 400 });
}
const { email, password } = c.input.body;
const user = findByEmail(email);
if (!user || !user.passwordHash) {
return Response.json({ error: "Invalid email or password" }, { status: 401 });
}
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
return Response.json({ error: "Invalid email or password" }, { status: 401 });
}
const sessionId = createSession(user.id);
return Response.json(
{ id: user.id, email: user.email },
{
status: 200,
headers: { "set-cookie": sessionCookie(sessionId) },
},
);
},
}),
route.post("/logout", {
resolve: (c) => {
const sessionId = getSessionId(c.request);
if (sessionId) deleteSession(sessionId);
return Response.json(
{ message: "Logged out" },
{ headers: { "set-cookie": clearSessionCookie() } },
);
},
}),
]); Notice the login route checks !user.passwordHash. If a user signed up with GitHub and has no password, they cannot use password login. The error message is the same (“Invalid email or password”) to prevent enumeration.
Account linking with passwords
The OAuth findOrCreateFromGithub and findOrCreateFromGoogle functions already link by email. If a user signs up with password and email [email protected], then logs in with GitHub (which has the same email), the GitHub ID is added to the existing account.
This works in the other direction too. If a user signs up with GitHub (no password), they can later add a password through a “set password” route:
route.post("/me/password", {
request: {
body: z.object({
password: z.string().min(8),
}),
},
resolve: async (c) => {
const user = authenticate(c.request);
if (user instanceof Response) return user;
if (!c.input.ok) {
return Response.json({ error: c.input.issues }, { status: 400 });
}
user.passwordHash = await bcrypt.hash(c.input.body.password, 10);
return Response.json({ message: "Password set" });
},
}); Now the user can log in with either their provider or their password.
The home page, updated
route.get("/", {
resolve: () =>
new Response(
`<h1>OAuth Course</h1>
<p><a href="/auth/github">Log in with GitHub</a></p>
<p><a href="/auth/google">Log in with Google</a></p>
<p>Or use <code>POST /signup</code> and <code>POST /login</code> for email/password.</p>`,
{ headers: { "content-type": "text/html" } },
),
}), Exercises
Exercise 1: Sign up with email/password. Then log in with GitHub using the same email. Visit /me and verify both passwordHash (not the value, just that it exists) and githubId are on the same user.
Exercise 2: Sign up with GitHub (no password). Try POST /login with your email and a random password. Verify you get 401 because the user has no passwordHash. Then add a password via POST /me/password and try again.
A user signed up with GitHub and has no password. What happens when they try POST /login with their email?