diff --git a/apps/backend/src/__tests__/auth.test.ts b/apps/backend/src/__tests__/auth.test.ts new file mode 100644 index 00000000..21891989 --- /dev/null +++ b/apps/backend/src/__tests__/auth.test.ts @@ -0,0 +1,146 @@ +import cookie from '@fastify/cookie'; +import jwt from '@fastify/jwt'; +import Fastify from 'fastify'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { authRoutes } from '../routes/auth.js'; + +import type { PrismaClient } from '@prisma/client'; + +const mockUser = { + id: 'user-123', + username: 'octocat', +}; + +const prismaMock = { + user: { + findUnique: vi.fn(), + upsert: vi.fn(), + }, + oAuthToken: { + upsert: vi.fn(), + }, +}; + +function mobileState(redirectUri: string): string { + const encodedRedirect = Buffer.from(redirectUri, 'utf8').toString('base64url'); + return `mobile_login.${encodedRedirect}.nonce`; +} + +async function buildApp() { + const app = Fastify(); + await app.register(jwt, { secret: 'test-secret' }); + await app.register(cookie); + app.decorate('prisma', prismaMock as unknown as PrismaClient); + app.decorate('authenticate', async () => {}); + await app.register(authRoutes, { prefix: '/auth' }); + await app.ready(); + return app; +} + +describe('auth mobile OAuth redirects', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv('BACKEND_URL', 'http://localhost:3000'); + vi.stubEnv('PUBLIC_APP_URL', 'http://localhost:5173'); + vi.stubEnv('MOBILE_REDIRECT_URI', 'devcard://auth/callback'); + vi.stubEnv('GITHUB_CLIENT_ID', 'github-client-id'); + vi.stubEnv('GITHUB_CLIENT_SECRET', 'github-client-secret'); + vi.stubEnv('ENCRYPTION_KEY', '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'); + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ + json: vi.fn().mockResolvedValue({ access_token: 'github-token', scope: 'read:user' }), + }) + .mockResolvedValueOnce({ + json: vi.fn().mockResolvedValue({ + id: 123, + login: 'octocat', + email: 'octocat@example.com', + name: 'Octo Cat', + avatar_url: 'https://example.com/avatar.png', + bio: null, + company: null, + }), + })); + prismaMock.user.upsert.mockResolvedValue(mockUser); + prismaMock.oAuthToken.upsert.mockResolvedValue({}); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + it('accepts an allowlisted mobile redirect URI', async () => { + const state = mobileState('devcard://auth/callback'); + const app = await buildApp(); + + const res = await app.inject({ + method: 'GET', + url: `/auth/github/callback?code=abc&state=${encodeURIComponent(state)}`, + headers: { cookie: `oauth_state=${state}` }, + }); + + expect(res.statusCode).toBe(302); + expect(res.headers.location).toMatch(/^devcard:\/\/auth\/callback#token=/); + await app.close(); + }); + + it('rejects an arbitrary https mobile redirect URI', async () => { + const state = mobileState('https://evil.example/callback'); + const app = await buildApp(); + + const res = await app.inject({ + method: 'GET', + url: `/auth/github/callback?code=abc&state=${encodeURIComponent(state)}`, + headers: { cookie: `oauth_state=${state}` }, + }); + + expect(res.statusCode).toBe(400); + expect(res.json()).toMatchObject({ error: 'Invalid mobile redirect URI' }); + expect(fetch).not.toHaveBeenCalled(); + expect(prismaMock.user.upsert).not.toHaveBeenCalled(); + await app.close(); + }); + + it('rejects a malformed mobile redirect URI', async () => { + const app = await buildApp(); + + const res = await app.inject({ + method: 'GET', + url: '/auth/github?state=mobile_login&mobile_redirect_uri=not-a-uri', + }); + + expect(res.statusCode).toBe(400); + expect(res.json()).toMatchObject({ error: 'Invalid mobile redirect URI' }); + await app.close(); + }); + + it('rejects an unknown mobile redirect scheme', async () => { + const app = await buildApp(); + + const res = await app.inject({ + method: 'GET', + url: '/auth/github?state=mobile_login&mobile_redirect_uri=evil://auth/callback', + }); + + expect(res.statusCode).toBe(400); + expect(res.json()).toMatchObject({ error: 'Invalid mobile redirect URI' }); + await app.close(); + }); + + it('preserves the existing web OAuth redirect flow', async () => { + const state = 'web_state.nonce'; + const app = await buildApp(); + + const res = await app.inject({ + method: 'GET', + url: `/auth/github/callback?code=abc&state=${encodeURIComponent(state)}`, + headers: { cookie: `oauth_state=${state}` }, + }); + + expect(res.statusCode).toBe(302); + expect(res.headers.location).toBe('http://localhost:5173/dashboard'); + await app.close(); + }); +}); diff --git a/apps/backend/src/__tests__/cards.test.ts b/apps/backend/src/__tests__/cards.test.ts index 3542a539..89048f34 100644 --- a/apps/backend/src/__tests__/cards.test.ts +++ b/apps/backend/src/__tests__/cards.test.ts @@ -1,4 +1,4 @@ -import Fastify, { type FastifyInstance } from 'fastify'; +import Fastify, { type FastifyInstance, type FastifyRequest } from 'fastify'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import { cardRoutes } from '../routes/cards.js'; @@ -48,15 +48,15 @@ const mockPrisma = { // against the same mock client, preserving existing per-operation mocks. function wireTransaction(): void { mockPrisma.$transaction.mockImplementation( - async (callback: (tx: typeof mockPrisma) => Promise) => callback(mockPrisma), + async (callback: (tx: typeof mockPrisma) => Promise, _options?: unknown) => callback(mockPrisma), ); } -async function buildApp():Promise { +async function buildApp(): Promise { const app = Fastify({ logger: false }); app.decorate('prisma', mockPrisma as unknown as PrismaClient); - app.decorate('authenticate', async (request: any) => { - request.user = { id: USER_ID }; + app.decorate('authenticate', async (request: FastifyRequest & { user?: { id: string } }) => { + (request as any).user = { id: USER_ID }; }); app.register(cardRoutes, { prefix: '/api/cards' }); await app.ready(); @@ -182,6 +182,55 @@ describe('POST /api/cards — link ownership validation', () => { expect(res.statusCode).toBe(500); }); + + it('wraps creation in a Serializable transaction to prevent race conditions', async () => { + mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]); + mockPrisma.card.count.mockResolvedValue(0); + mockPrisma.card.create.mockResolvedValue({ ...mockCard, cardLinks: [] }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/cards', + payload: { title: 'Test Card', linkIds: [OWNED_LINK_ID] }, + }); + + expect(res.statusCode).toBe(201); + expect(mockPrisma.$transaction).toHaveBeenCalledWith( + expect.any(Function), + { isolationLevel: 'Serializable' } + ); + }); + + it('retries the transaction on P2034 serialization failure', async () => { + mockPrisma.platformLink.findMany.mockResolvedValue([]); + + // First attempt fails with P2034 (serialization conflict) + // Second attempt succeeds + const error = new Error('Serialization failure') as Error & { code: string }; + error.code = 'P2034'; + + // We mock $transaction to fail once, then succeed + mockPrisma.$transaction + .mockRejectedValueOnce(error) + .mockImplementationOnce( + async (callback: (tx: typeof mockPrisma) => Promise) => callback(mockPrisma) + ); + + mockPrisma.card.count.mockResolvedValue(1); // second attempt sees count > 0 + mockPrisma.card.create.mockResolvedValue({ ...mockCard, isDefault: false, cardLinks: [] }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/cards', + payload: { title: 'Test Card', linkIds: [] }, + }); + + expect(res.statusCode).toBe(201); + expect(res.json().isDefault).toBe(false); + expect(mockPrisma.$transaction).toHaveBeenCalledTimes(2); + }); }); // ───────────────────────────────────────────────────────────────────────────── @@ -440,4 +489,4 @@ describe('PUT /api/cards/:id/default', () => { expect(mockPrisma.card.updateMany).toHaveBeenCalled(); expect(mockPrisma.card.update).toHaveBeenCalled(); }); -}); \ No newline at end of file +}); diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index c14949e1..b420436f 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -1,6 +1,7 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { buildOAuthState, getMobileRedirectUri, isAllowedMobileRedirectUri } from '../services/authService.js'; import { encrypt } from '../utils/encryption.js'; -import { buildOAuthState, getMobileRedirectUri } from '../services/authService.js'; + +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize'; const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'; @@ -8,6 +9,7 @@ const GITHUB_USER_URL = 'https://api.github.com/user'; const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'; const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; const GOOGLE_USER_URL = 'https://www.googleapis.com/oauth2/v2/userinfo'; +const INVALID_MOBILE_REDIRECT_ERROR = 'Invalid mobile redirect URI'; interface OAuthCallbackQuery { code: string; @@ -32,6 +34,9 @@ export async function authRoutes(app: FastifyInstance) { const redirectUri = `${process.env.BACKEND_URL}/auth/github/callback`; const clientState = (request.query as any).state || ''; const mobileRedirectUri = (request.query as any).mobile_redirect_uri || ''; + if (clientState.startsWith('mobile_') && mobileRedirectUri && !isAllowedMobileRedirectUri(mobileRedirectUri)) { + return reply.status(400).send({ error: INVALID_MOBILE_REDIRECT_ERROR }); + } const state = buildOAuthState(clientState, mobileRedirectUri); reply.setCookie('oauth_state', state, { @@ -66,6 +71,10 @@ export async function authRoutes(app: FastifyInstance) { if (!code) { return reply.status(400).send({ error: 'Missing authorization code' }); } + const mobileRedirect = state?.startsWith('mobile_') ? getMobileRedirectUri(state) : null; + if (state?.startsWith('mobile_') && !mobileRedirect) { + return reply.status(400).send({ error: INVALID_MOBILE_REDIRECT_ERROR }); + } try { const tokenRes = await fetch(GITHUB_TOKEN_URL, { @@ -131,7 +140,6 @@ export async function authRoutes(app: FastifyInstance) { const token = app.jwt.sign({ id: user.id, username: user.username }, { expiresIn: '30d' }); if (request.query.state?.startsWith('mobile_')) { - const mobileRedirect = getMobileRedirectUri(request.query.state) || process.env.MOBILE_REDIRECT_URI; return reply.redirect(`${mobileRedirect}#token=${token}`); } @@ -155,6 +163,9 @@ export async function authRoutes(app: FastifyInstance) { const redirectUri = `${process.env.BACKEND_URL}/auth/google/callback`; const clientState = (request.query as any).state || ''; const mobileRedirectUri = (request.query as any).mobile_redirect_uri || ''; + if (clientState.startsWith('mobile_') && mobileRedirectUri && !isAllowedMobileRedirectUri(mobileRedirectUri)) { + return reply.status(400).send({ error: INVALID_MOBILE_REDIRECT_ERROR }); + } const state = buildOAuthState(clientState, mobileRedirectUri); reply.setCookie('oauth_state', state, { @@ -192,6 +203,10 @@ export async function authRoutes(app: FastifyInstance) { if (!code) { return reply.status(400).send({ error: 'Missing authorization code' }); } + const mobileRedirect = state?.startsWith('mobile_') ? getMobileRedirectUri(state) : null; + if (state?.startsWith('mobile_') && !mobileRedirect) { + return reply.status(400).send({ error: INVALID_MOBILE_REDIRECT_ERROR }); + } try { const tokenRes = await fetch(GOOGLE_TOKEN_URL, { @@ -233,7 +248,6 @@ export async function authRoutes(app: FastifyInstance) { const token = app.jwt.sign({ id: user.id, username: user.username }, { expiresIn: '30d' }); if (request.query.state?.startsWith('mobile_')) { - const mobileRedirect = getMobileRedirectUri(request.query.state) || process.env.MOBILE_REDIRECT_URI; return reply.redirect(`${mobileRedirect}#token=${token}`); } @@ -257,7 +271,7 @@ export async function authRoutes(app: FastifyInstance) { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } + try { await request.jwtVerify() } catch (_e) { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async (request: FastifyRequest, reply: FastifyReply) => { const userId = (request.user as any).id; const user = await app.prisma.user.findUnique({ diff --git a/apps/backend/src/routes/cards.ts b/apps/backend/src/routes/cards.ts index e5f98762..3df590bb 100644 --- a/apps/backend/src/routes/cards.ts +++ b/apps/backend/src/routes/cards.ts @@ -2,10 +2,10 @@ import * as cardService from '../services/cardService' import { handleDbError } from '../utils/error.util.js'; import { createCardSchema, updateCardSchema } from '../utils/validators.js'; -import type { CardResponse } from '../services/cardService'; import type { Card } from '@devcard/shared'; import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + interface CreateCardBody { title: string; linkIds: string[]; @@ -20,33 +20,6 @@ interface CardParams { id: string; } -interface PlatformLink { - id: string; - userId: string; - platform: string; - username: string; - url: string; - displayOrder: number; - createdAt: Date; -} - -interface CardLinkWithPlatform { - id: string; - cardId: string; - platformLinkId: string; - displayOrder: number; - platformLink: PlatformLink; -} - -interface _CardWithLinks { - id: string; - userId: string; - title: string; - isDefault: boolean; - createdAt: Date; - updatedAt: Date; - cardLinks: CardLinkWithPlatform[]; -} export async function cardRoutes(app: FastifyInstance): Promise { app.addHook('preHandler', async (request, reply) => { @@ -58,7 +31,7 @@ export async function cardRoutes(app: FastifyInstance): Promise { // ─── List Cards ─── - app.get('/', async (request: FastifyRequest, reply: FastifyReply): Promise => { + app.get('/', async (request: FastifyRequest, reply: FastifyReply) => { const userId = (request.user as { id: string }).id; try { return await cardService.listCards(app, userId) @@ -88,7 +61,7 @@ export async function cardRoutes(app: FastifyInstance): Promise { // ─── Update Card ─── - app.put('/:id', async (request: FastifyRequest<{ Params: CardParams; Body: UpdateCardBody }>, reply: FastifyReply): Promise => { + app.put('/:id', async (request: FastifyRequest<{ Params: CardParams; Body: UpdateCardBody }>, reply: FastifyReply) => { const userId = (request.user as { id: string }).id; const { id } = request.params; @@ -113,16 +86,9 @@ export async function cardRoutes(app: FastifyInstance): Promise { try { await cardService.deleteCard(app, userId, id) return reply.status(204).send() - } catch (error:any) { - if (error?.code === 'NOT_FOUND') { - return reply.status(404).send({ error: 'Card not found' }); - } - - if (error?.code === 'LAST_CARD') { - return reply.status(400).send({ - error: 'Cannot delete the last remaining card. A user must have at least one card.', - }); - } + } catch (error: any) { + if (error?.code === 'NOT_FOUND') {return reply.status(404).send({ error: 'Card not found' })} + if (error?.code === 'LAST_CARD') {return reply.status(400).send({ error: 'Cannot delete the last remaining card. A user must have at least one card.' })} return handleDbError(error, request, reply) } }); diff --git a/apps/backend/src/services/authService.ts b/apps/backend/src/services/authService.ts index 9af718c5..16f4db8e 100644 --- a/apps/backend/src/services/authService.ts +++ b/apps/backend/src/services/authService.ts @@ -1,4 +1,4 @@ -import { randomBytes } from 'crypto'; +import { randomBytes } from 'node:crypto'; export function generateState(): string { return randomBytes(32).toString('hex'); @@ -22,14 +22,41 @@ export function getMobileRedirectUri(state?: string): string | null { return null; } - const encodedRedirect = state.split('.')[1]; - if (!encodedRedirect) { - return null; + const stateParts = state.split('.'); + const encodedRedirect = stateParts[1]; + if (stateParts.length < 3 || !encodedRedirect) { + const configuredRedirect = process.env.MOBILE_REDIRECT_URI; + return configuredRedirect && isAllowedMobileRedirectUri(configuredRedirect) ? configuredRedirect : null; } try { - return Buffer.from(encodedRedirect, 'base64url').toString('utf8'); + const redirectUri = Buffer.from(encodedRedirect, 'base64url').toString('utf8'); + return isAllowedMobileRedirectUri(redirectUri) ? redirectUri : null; } catch { return null; } } + +export function isAllowedMobileRedirectUri(redirectUri: string): boolean { + const configuredRedirect = process.env.MOBILE_REDIRECT_URI; + if (!configuredRedirect) { + return false; + } + + try { + const candidate = new URL(redirectUri); + const allowed = new URL(configuredRedirect); + + if (candidate.protocol !== allowed.protocol) { + return false; + } + + if (candidate.protocol === 'http:' || candidate.protocol === 'https:') { + return candidate.origin === allowed.origin; + } + + return candidate.host === allowed.host; + } catch { + return false; + } +}