Skip to content
Merged
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
210 changes: 210 additions & 0 deletions apps/backend/src/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import Fastify from 'fastify';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';

import { authRoutes } from '../routes/auth';

import type { JWT } from '@fastify/jwt';
import type { PrismaClient } from '@prisma/client';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import type { Redis } from 'ioredis';


const MOCK_CLIENT_ID = 'mock-github-client-id';
const MOCK_GOOGLE_CLIENT_ID = 'mock-google-client-id';
const MOCK_BACKEND_URL = 'http://localhost:3000';


async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({ logger: false });

await app.register(import('@fastify/cookie'));

//as not testing this here
app.decorate('authenticate', async (_request: FastifyRequest, reply: FastifyReply) => {
reply.status(401).send({ error: 'Unauthorized' });
});

app.decorate('jwt', {
sign: vi.fn().mockReturnValue('mock-token'),
decode: vi.fn(),
verify: vi.fn(),
} as unknown as JWT);

app.decorate('prisma', {
user: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() },
userIdentity: { findUnique: vi.fn(), create: vi.fn() },
refreshToken: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn(), updateMany: vi.fn() },
} as unknown as PrismaClient);

app.decorate('redis', {
set: vi.fn(),
get: vi.fn(),
getdel: vi.fn(),
} as unknown as Redis);

await app.register(authRoutes, { prefix: '/auth' });
await app.ready();
return app;
}

describe('Auth API — OAuth initiation', () => {
let app: FastifyInstance;

beforeEach(async () => {
vi.clearAllMocks();
vi.stubEnv('GITHUB_CLIENT_ID', MOCK_CLIENT_ID);
vi.stubEnv('GOOGLE_CLIENT_ID', MOCK_GOOGLE_CLIENT_ID);
vi.stubEnv('BACKEND_URL', MOCK_BACKEND_URL);
vi.stubEnv('NODE_ENV', 'test');
app = await buildApp(); //fresh app instance before and after each instance
});

afterEach(async () => {
vi.unstubAllEnvs();
await app.close(); //fresh app instance before and after each instance
});

// /auth/github
describe('GET /auth/github — OAuth initiation', () => {
it('302 — redirects to GitHub with valid query params', async () => {
const res = await app.inject({
method: 'GET',
url: '/auth/github',
});

expect(res.statusCode).toBe(302);
expect(res.headers.location).toContain('github.com/login/oauth/authorize');
});

it('302 — sets oauth_state cookie on redirect', async () => {
const res = await app.inject({
method: 'GET',
url: '/auth/github',
});

expect(res.statusCode).toBe(302);
expect(res.headers['set-cookie']).toBeDefined();
expect(res.headers['set-cookie']).toMatch(/oauth_state=/);
});

it('302 — accepts valid state param', async () => {
const res = await app.inject({
method: 'GET',
url: '/auth/github?state=some-client-state',
});

expect(res.statusCode).toBe(302);
});

it('302 — accepts valid mobile_redirect_uri', async () => {
const res = await app.inject({
method: 'GET',
url: '/auth/github?mobile_redirect_uri=devcard://callback',
});

expect(res.statusCode).toBe(302);
});

it('400 — rejects invalid mobile_redirect_uri', async () => {
const res = await app.inject({
method: 'GET',
url: '/auth/github?mobile_redirect_uri=https://evil.com/callback',
});

expect(res.statusCode).toBe(400);
expect(res.json()).toMatchObject({ error: expect.any(String) });
});

it('400 — rejects mobile_redirect_uri that is not devcard:// scheme', async () => {
const res = await app.inject({
method: 'GET',
url: '/auth/github?mobile_redirect_uri=http://localhost/callback',
});

expect(res.statusCode).toBe(400);
});

it('400 — returns 400 when GITHUB_CLIENT_ID is missing', async () => {
vi.stubEnv('GITHUB_CLIENT_ID', '');

const res = await app.inject({
method: 'GET',
url: '/auth/github',
});

expect(res.statusCode).toBe(400);
});
});

// /auth/google
describe('GET /auth/google — OAuth initiation', () => {
it('302 — redirects to Google with valid query params', async () => {
const res = await app.inject({
method: 'GET',
url: '/auth/google',
});

expect(res.statusCode).toBe(302);
expect(res.headers.location).toContain('accounts.google.com/o/oauth2/v2/auth');
});

it('302 — sets oauth_state cookie on redirect', async () => {
const res = await app.inject({
method: 'GET',
url: '/auth/google',
});

expect(res.statusCode).toBe(302);
expect(res.headers['set-cookie']).toBeDefined();
expect(res.headers['set-cookie']).toMatch(/oauth_state=/);
});

it('302 — accepts valid state param', async () => {
const res = await app.inject({
method: 'GET',
url: '/auth/google?state=some-client-state',
});

expect(res.statusCode).toBe(302);
});

it('302 — accepts valid mobile_redirect_uri', async () => {
const res = await app.inject({
method: 'GET',
url: '/auth/google?mobile_redirect_uri=devcard://callback',
});

expect(res.statusCode).toBe(302);
});

it('400 — rejects invalid mobile_redirect_uri', async () => {
const res = await app.inject({
method: 'GET',
url: '/auth/google?mobile_redirect_uri=https://evil.com/callback',
});

expect(res.statusCode).toBe(400);
expect(res.json()).toMatchObject({ error: expect.any(String) });
});

it('400 — rejects mobile_redirect_uri that is not devcard:// scheme', async () => {
const res = await app.inject({
method: 'GET',
url: '/auth/google?mobile_redirect_uri=http://localhost/callback',
});

expect(res.statusCode).toBe(400);
});

it('400 — returns 400 when GOOGLE_CLIENT_ID is missing', async () => {
vi.stubEnv('GOOGLE_CLIENT_ID', '');

const res = await app.inject({
method: 'GET',
url: '/auth/google',
});

expect(res.statusCode).toBe(400);
});
});
});
42 changes: 14 additions & 28 deletions apps/backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { handleDbError, isGitHubTokenError, isGoogleTokenError } from '../utils/
import { extractRawJwt, blocklistKey, signAccessToken } from '../utils/jwt.js';
import { buildOAuthState, getMobileRedirectUri } from '../utils/oauth.js';
import { generateRefreshToken, hashIp, hashRefreshToken } from '../utils/refreshToken.js';
import { oAuthStartSchema } from '../validations/auth.validation.js';

import type { GitHubTokenErrorResponse, GitHubTokenResponse } from '../utils/error.util.js';
import type { OAuthStartQuery } from '../validations/auth.validation.js';
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';

interface GitHubEmailResponse {
Expand All @@ -24,11 +26,6 @@ interface OAuthCallbackQuery {
state?: string;
}

type GoogleAuthQuery = {
state?: string;
mobile_redirect_uri?: string;
};

interface GoogleUser {
id: string;
email: string;
Expand Down Expand Up @@ -66,23 +63,18 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
}

// GitHub OAuth start
app.get('/github', async (request: FastifyRequest<{Querystring: GoogleAuthQuery}>, reply: FastifyReply) => {
app.get('/github', async (request: FastifyRequest<{Querystring: OAuthStartQuery}>, reply: FastifyReply) => {
const clientId = process.env.GITHUB_CLIENT_ID;
if(!clientId){
return reply.status(400).send()
}
//TODO: Add zod validation here
const { state: clientState = '', mobile_redirect_uri: mobileRedirectUri = '' } = request.query

if (
mobileRedirectUri &&
!mobileRedirectUri.startsWith('devcard://')
) {
return reply.status(400).send({
error: 'Invalid mobile redirect URI',
});
}
const redirectUri = `${process.env.BACKEND_URL}/auth/github/callback`;

const parsed = oAuthStartSchema.safeParse(request.query);
if (!parsed.success) {
return reply.status(400).send({ error: parsed.error.errors[0].message });
}
const { state: clientState, mobile_redirect_uri: mobileRedirectUri } = parsed.data;
const state = buildOAuthState(clientState, mobileRedirectUri);

reply.setCookie('oauth_state', state, {
Expand Down Expand Up @@ -288,24 +280,18 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
});

// Google OAuth start
app.get('/google', async (request: FastifyRequest<{Querystring: GoogleAuthQuery}>, reply: FastifyReply) => {
app.get('/google', async (request: FastifyRequest<{Querystring: OAuthStartQuery}>, reply: FastifyReply) => {
const clientId = process.env.GOOGLE_CLIENT_ID;
if(!clientId){
return reply.status(400).send()
}
const redirectUri = `${process.env.BACKEND_URL}/auth/google/callback`;
//TODO: Add zod validation here
const { state: clientState = '', mobile_redirect_uri: mobileRedirectUri = '' } = request.query

if (
mobileRedirectUri &&
!mobileRedirectUri.startsWith('devcard://')
) {
return reply.status(400).send({
error: 'Invalid mobile redirect URI',
});
const parsed = oAuthStartSchema.safeParse(request.query);
if (!parsed.success) {
return reply.status(400).send({ error: parsed.error.errors[0].message });
}

const { state: clientState, mobile_redirect_uri: mobileRedirectUri } = parsed.data;
const state = buildOAuthState(clientState, mobileRedirectUri);

reply.setCookie('oauth_state', state, {
Expand Down
15 changes: 15 additions & 0 deletions apps/backend/src/validations/auth.validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { z } from 'zod';

export const oAuthStartSchema = z.object({
state: z.string().optional().default(''),
mobile_redirect_uri: z
.string()
.optional()
.default('')
.refine(
(val) => !val || val.startsWith('devcard://'),
{ message: 'Invalid mobile redirect URI' }
),
});

export type OAuthStartQuery = z.infer<typeof oAuthStartSchema>;