Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@fastify/multipart": "^9.0.0",
"@fastify/static": "^8.0.0",
"@prisma/client": "^6.0.0",
"bcrypt": "^5.1.1",
"dotenv": "^16.4.0",
"fastify": "^5.0.0",
"fastify-plugin": "^5.0.0",
Expand All @@ -33,6 +34,7 @@
"zod": "^3.23.0"
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/node": "^22.0.0",
"@types/qrcode": "^1.5.0",
"eslint": "^10.4.0",
Expand Down
Comment thread
pranshugarg637 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "users" ADD COLUMN "password_hash" TEXT;
1 change: 1 addition & 0 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ model User {
accentColor String @default("#6366f1") @map("accent_color")
provider String
providerId String @map("provider_id")
passwordHash String? @map("password_hash")
Comment thread
pranshugarg637 marked this conversation as resolved.
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

Expand Down
181 changes: 181 additions & 0 deletions apps/backend/src/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import Fastify from 'fastify';
import jwt from '@fastify/jwt';
import cookie from '@fastify/cookie';
import { authRoutes } from '../routes/auth.js';

vi.mock('bcrypt', () => ({
default: {
hash: vi.fn(async (password: string) => `hashed:${password}`),
compare: vi.fn(async (password: string, hash: string) => hash === `hashed:${password}`),
},
}));

const createdAt = new Date('2026-05-17T00:00:00.000Z');

const mockUser = {
id: 'user-1',
email: 'dev@example.com',
username: 'devuser',
displayName: 'Dev User',
bio: null,
pronouns: null,
role: null,
company: null,
avatarUrl: null,
accentColor: '#6366f1',
provider: 'local',
providerId: 'dev@example.com',
passwordHash: 'hashed:password123',
createdAt,
updatedAt: createdAt,
};

const mockPrisma = {
user: {
create: vi.fn(),
findFirst: vi.fn(),
findUnique: vi.fn(),
},
};

async function buildApp() {
const app = Fastify();
app.register(jwt, { secret: 'test-secret' });
app.register(cookie);
app.decorate('prisma', mockPrisma);
app.decorate('authenticate', async function (request: any, reply: any) {
try {
await request.jwtVerify();
} catch {
reply.status(401).send({ error: 'Unauthorized' });
}
});
app.register(authRoutes, { prefix: '/auth' });
await app.ready();
return app;
}

describe('local auth routes', () => {
beforeEach(() => vi.clearAllMocks());

it('registers a user with a hashed password and returns a JWT', async () => {
mockPrisma.user.findFirst.mockResolvedValue(null);
mockPrisma.user.create.mockResolvedValue({
id: mockUser.id,
email: mockUser.email,
username: mockUser.username,
displayName: mockUser.displayName,
bio: null,
pronouns: null,
role: null,
company: null,
avatarUrl: null,
accentColor: mockUser.accentColor,
createdAt,
});

const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/auth/register',
payload: {
email: 'Dev@Example.com',
username: 'devuser',
displayName: 'Dev User',
password: 'password123',
},
});

expect(res.statusCode).toBe(201);
expect(res.json().token).toBeTruthy();
expect(res.json().user.passwordHash).toBeUndefined();
expect(mockPrisma.user.create).toHaveBeenCalledWith({
data: {
email: 'dev@example.com',
username: 'devuser',
displayName: 'Dev User',
provider: 'local',
providerId: 'dev@example.com',
passwordHash: 'hashed:password123',
},
select: expect.any(Object),
});

await app.close();
});

it('rejects duplicate emails during registration', async () => {
mockPrisma.user.findFirst.mockResolvedValue({ email: 'dev@example.com', username: 'other' });

const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/auth/register',
payload: {
email: 'dev@example.com',
username: 'devuser',
displayName: 'Dev User',
password: 'password123',
},
});

expect(res.statusCode).toBe(409);
expect(res.json().error).toBe('Email already registered');
expect(mockPrisma.user.create).not.toHaveBeenCalled();

await app.close();
});

it('logs in a local user with valid credentials', async () => {
mockPrisma.user.findUnique.mockResolvedValue(mockUser);

const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/auth/login',
payload: {
email: 'dev@example.com',
password: 'password123',
},
});

const body = res.json();
expect(res.statusCode).toBe(200);
expect(body.token).toBeTruthy();
expect(body.user.passwordHash).toBeUndefined();
expect(body.user.provider).toBeUndefined();
expect(body.user.providerId).toBeUndefined();

await app.close();
});

it('rejects invalid login credentials', async () => {
mockPrisma.user.findUnique.mockResolvedValue(mockUser);

const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/auth/login',
payload: {
email: 'dev@example.com',
password: 'wrong-password',
},
});

expect(res.statusCode).toBe(401);
expect(res.json().error).toBe('Invalid email or password');

await app.close();
});

it('protects authenticated routes with JWT middleware', async () => {
const app = await buildApp();
const res = await app.inject({ method: 'GET', url: '/auth/me' });

expect(res.statusCode).toBe(401);
expect(res.json().error).toBe('Unauthorized');

await app.close();
});
});
140 changes: 115 additions & 25 deletions apps/backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { randomBytes } from 'crypto';
import bcrypt from 'bcrypt';
import { loginSchema, registerSchema } from '../utils/validators.js';

const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize';
const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token';
const GITHUB_USER_URL = 'https://api.github.com/user';
Expand All @@ -12,7 +14,77 @@ interface OAuthCallbackQuery {
state?: string;
}

const PASSWORD_SALT_ROUNDS = 12;

export async function authRoutes(app: FastifyInstance) {
app.post('/register', async (request: FastifyRequest, reply: FastifyReply) => {
Comment thread
pranshugarg637 marked this conversation as resolved.
const parsed = registerSchema.safeParse(request.body);

if (!parsed.success) {
return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() });
}

const { email, username, displayName, password } = parsed.data;
const existingUser = await app.prisma.user.findFirst({
Comment thread
pranshugarg637 marked this conversation as resolved.
where: {
OR: [{ email }, { username }],
},
select: { email: true, username: true },
});

if (existingUser?.email === email) {
return reply.status(409).send({ error: 'Email already registered' });
}

if (existingUser?.username === username) {
return reply.status(409).send({ error: 'Username already taken' });
}

const passwordHash = await bcrypt.hash(password, PASSWORD_SALT_ROUNDS);
const user = await app.prisma.user.create({
data: {
email,
username,
displayName,
provider: 'local',
providerId: email,
passwordHash,
},
select: userSelect,
});

const token = signAuthToken(app, user);
setAuthCookie(reply, token);

return reply.status(201).send({ token, user });
});

app.post('/login', async (request: FastifyRequest, reply: FastifyReply) => {
const parsed = loginSchema.safeParse(request.body);

if (!parsed.success) {
return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() });
}

const { email, password } = parsed.data;
const user = await app.prisma.user.findUnique({ where: { email } });

if (!user?.passwordHash) {
return reply.status(401).send({ error: 'Invalid email or password' });
}

const passwordMatches = await bcrypt.compare(password, user.passwordHash);
if (!passwordMatches) {
return reply.status(401).send({ error: 'Invalid email or password' });
}

const token = signAuthToken(app, user);
setAuthCookie(reply, token);

const { passwordHash, provider, providerId, ...safeUser } = user;
return { token, user: safeUser };
});

// ─── GitHub OAuth ───

app.get('/github', async (request: FastifyRequest, reply: FastifyReply) => {
Expand Down Expand Up @@ -112,10 +184,7 @@ export async function authRoutes(app: FastifyInstance) {
});

// Generate JWT
const token = app.jwt.sign(
{ id: user.id, username: user.username },
{ expiresIn: '30d' }
);
const token = signAuthToken(app, user);

// For mobile app: redirect with token as URL fragment (not sent to servers, keeps token out of logs)
const mobileRedirect = process.env.MOBILE_REDIRECT_URI;
Expand All @@ -124,17 +193,11 @@ export async function authRoutes(app: FastifyInstance) {
}

// For web: set cookie and redirect
reply.setCookie('token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 30 * 24 * 60 * 60, // 30 days
});
setAuthCookie(reply, token);

return reply.redirect(`${process.env.PUBLIC_APP_URL}/dashboard`);
} catch (err) {
app.log.error('GitHub auth error:', err);
app.log.error({ err }, 'GitHub auth error');
return reply.status(500).send({ error: 'Authentication failed' });
}
});
Expand Down Expand Up @@ -215,27 +278,18 @@ export async function authRoutes(app: FastifyInstance) {
},
});

const token = app.jwt.sign(
{ id: user.id, username: user.username },
{ expiresIn: '30d' }
);
const token = signAuthToken(app, user);

if (request.query.state?.startsWith('mobile_')) {
const mobileRedirect = process.env.MOBILE_REDIRECT_URI;
return reply.redirect(`${mobileRedirect}#token=${token}`);
}

reply.setCookie('token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 30 * 24 * 60 * 60,
});
setAuthCookie(reply, token);

return reply.redirect(`${process.env.PUBLIC_APP_URL}/dashboard`);
} catch (err) {
app.log.error('Google auth error:', err);
app.log.error({ err }, 'Google auth error');
return reply.status(500).send({ error: 'Authentication failed' });
}
});
Expand Down Expand Up @@ -289,3 +343,39 @@ export async function authRoutes(app: FastifyInstance) {
function generateState(): string {
return randomBytes(32).toString('hex');
}

const userSelect = {
id: true,
email: true,
username: true,
displayName: true,
bio: true,
pronouns: true,
role: true,
company: true,
avatarUrl: true,
accentColor: true,
createdAt: true,
};

type AuthUser = {
id: string;
username: string;
};

function signAuthToken(app: FastifyInstance, user: AuthUser): string {
return app.jwt.sign(
{ id: user.id, username: user.username },
{ expiresIn: '30d' }
);
}

function setAuthCookie(reply: FastifyReply, token: string) {
reply.setCookie('token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 30 * 24 * 60 * 60,
});
}
Loading