diff --git a/docker-compose.yml b/docker-compose.yml index 38bfd80..616055f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,7 @@ name: coverit services: api: image: ghcr.io/coveritlabs/coverit-api:${API_TAG:-dev} + pull_policy: always container_name: coverit-api ports: - "3000:3000" @@ -20,6 +21,13 @@ services: - JWT_REFRESH_EXPIRY_SECONDS=${JWT_REFRESH_EXPIRY_SECONDS:-604800} - RESET_TOKEN_TTL_SECONDS=${RESET_TOKEN_TTL_SECONDS:-900} - CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:5173} + - OAUTH_FRONTEND_URL=${OAUTH_FRONTEND_URL:-http://localhost:5173} + - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} + - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-} + - GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL:-http://localhost:3000/api/v1/auth/oauth/google/callback} + - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-} + - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-} + - GITHUB_CALLBACK_URL=${GITHUB_CALLBACK_URL:-http://localhost:3000/api/v1/auth/oauth/github/callback} restart: unless-stopped depends_on: db: diff --git a/package.json b/package.json index a5f16c3..88f2f85 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", + "http-status-codes": "^2.3.0", "helmet": "^7.1.0", "ioredis": "^5.10.0", "jsonwebtoken": "^9.0.3", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b7d014e..81bb6d3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,8 +14,9 @@ datasource db { model User { id String @id @default(uuid()) @db.Uuid email String @unique - password String + password String? name String + provider String @default("local") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/src/__tests__/controllers/auth.controller.test.ts b/src/__tests__/controllers/auth.controller.test.ts new file mode 100644 index 0000000..e53b576 --- /dev/null +++ b/src/__tests__/controllers/auth.controller.test.ts @@ -0,0 +1,127 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import express from 'express'; +import request from 'supertest'; + +jest.mock('@services/auth.service'); +jest.mock('@services/oauth.service'); +jest.mock('@config/env', () => ({ env: { OAUTH_FRONTEND_URL: 'https://app.example.com' } })); + +import * as authService from '@services/auth.service'; +import * as oauthService from '@services/oauth.service'; +import { env } from '@config/env'; +import { + signup, + login, + refresh, + logout, + forgotPassword, + resetPassword, + oauthRedirect, + oauthCallback, +} from '@api/controllers/auth.controller'; +import { AUTH_MESSAGES } from '@constants/messages'; + +function makeApp() { + const a = express(); + a.use(express.json()); + a.post('/signup', signup); + a.post('/login', login); + a.post('/refresh', refresh); + a.post('/logout', logout); + a.post('/forgot', forgotPassword); + a.post('/reset', resetPassword); + a.get('/oauth/:provider/redirect', oauthRedirect); + a.get('/oauth/:provider/callback', oauthCallback); + return a; +} + +describe('auth.controller', () => { + let app: express.Application; + + beforeEach(() => { + jest.resetAllMocks(); + app = makeApp(); + }); + + test('signup returns 201 on success', async () => { + (authService.signup as jest.Mock).mockResolvedValue({ message: 'ok' }); + const res = await request(app).post('/signup').send({ email: 'a@b.com', password: 'p', name: 'n' }); + expect(res.status).toBe(201); + expect(res.body).toEqual({ message: 'ok' }); + }); + + test('login returns 200 on success', async () => { + (authService.login as jest.Mock).mockResolvedValue({ tokens: {}, user: {} }); + const res = await request(app).post('/login').send({ email: 'a@b.com', password: 'p' }); + expect(res.status).toBe(200); + }); + + test('refresh returns 200 on success', async () => { + (authService.refresh as jest.Mock).mockResolvedValue({ tokens: {} }); + const res = await request(app).post('/refresh').send({ refreshToken: 'rt' }); + expect(res.status).toBe(200); + }); + + test('logout with refreshToken calls service', async () => { + (authService.logout as jest.Mock).mockResolvedValue({ message: 'logged out' }); + const res = await request(app).post('/logout').send({ refreshToken: 'x' }); + expect(res.status).toBe(200); + expect(authService.logout).toHaveBeenCalledWith('x'); + }); + + test('logout without refreshToken returns success message', async () => { + const res = await request(app).post('/logout').send({}); + expect(res.status).toBe(200); + expect(res.body.message).toBe(AUTH_MESSAGES.LOGOUT_SUCCESS); + }); + + test('forgotPassword triggers service and returns 200', async () => { + (authService.forgotPassword as jest.Mock).mockResolvedValue(undefined); + const res = await request(app).post('/forgot').send({ email: 'a@b.com' }); + expect(res.status).toBe(200); + expect(authService.forgotPassword).toHaveBeenCalledWith({ email: 'a@b.com' }); + }); + + test('resetPassword returns 200 on success', async () => { + (authService.resetPassword as jest.Mock).mockResolvedValue({ message: 'ok' }); + const res = await request(app).post('/reset').send({ token: 't', newPassword: 'np' }); + expect(res.status).toBe(200); + }); + + test('oauthRedirect returns 400 for unsupported provider', async () => { + const res = await request(app).get('/oauth/unknown/redirect'); + expect(res.status).toBe(400); + expect(res.body.message).toBe(AUTH_MESSAGES.UNSUPPORTED_OAUTH_PROVIDER); + }); + + test('oauthRedirect redirects to provider authorization url', async () => { + (oauthService.getAuthorizationUrl as jest.Mock).mockReturnValue('https://auth.example'); + const res = await request(app).get('/oauth/google/redirect'); + expect(res.status).toBe(302); + expect(res.header.location).toBe('https://auth.example'); + }); + + test('oauthCallback redirects to login when code missing', async () => { + const res = await request(app).get('/oauth/google/callback'); + expect(res.status).toBe(302); + expect(res.header.location).toBe(`${env.OAUTH_FRONTEND_URL}/login?error=${encodeURIComponent(AUTH_MESSAGES.OAUTH_CODE_MISSING)}`); + }); + + test('oauthCallback redirects back to frontend on success', async () => { + (oauthService.exchangeCodeForProfile as jest.Mock).mockResolvedValue({ email: 'a@b.com', name: 'A' }); + (authService.oauthLogin as jest.Mock).mockResolvedValue({ tokens: { accessToken: 'a', refreshToken: 'r' }, user: { id: 'u', email: 'a@b.com', name: 'A' } }); + const res = await request(app).get('/oauth/google/callback').query({ code: 'c' }); + expect(res.status).toBe(302); + expect(res.header.location).toContain(`${env.OAUTH_FRONTEND_URL}/oauth/callback?`); + }); + + test('oauthCallback redirects to login with error when exchange fails', async () => { + (oauthService.exchangeCodeForProfile as jest.Mock).mockRejectedValue(new Error('fail')); + const res = await request(app).get('/oauth/google/callback').query({ code: 'c' }); + expect(res.status).toBe(302); + expect(res.header.location).toContain('/login?error='); + }); +}); diff --git a/src/__tests__/middlewares/requireAuth.test.ts b/src/__tests__/middlewares/requireAuth.test.ts index 9a1e4c8..ba1b31f 100644 --- a/src/__tests__/middlewares/requireAuth.test.ts +++ b/src/__tests__/middlewares/requireAuth.test.ts @@ -102,6 +102,16 @@ describe('requireAuth middleware', () => { }); describe('errorHandler middleware', () => { + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + it('should return 500 for unknown errors', async () => { const errApp = express(); errApp.use(express.json()); @@ -114,5 +124,6 @@ describe('errorHandler middleware', () => { expect(res.status).toBe(500); expect(res.body.message).toBe('Internal server error'); + expect(consoleErrorSpy).toHaveBeenCalledWith('Unhandled error:', expect.any(Error)); }); }); diff --git a/src/__tests__/services/auth.service.test.ts b/src/__tests__/services/auth.service.test.ts index aedc2a5..2e1903a 100644 --- a/src/__tests__/services/auth.service.test.ts +++ b/src/__tests__/services/auth.service.test.ts @@ -270,3 +270,43 @@ describe('verifyAccessToken', () => { expect(() => verifyAccessToken(token)).toThrow('Malformed token'); }); }); + +describe('authService.oauthLogin', () => { + it('should create a new user when none exists and return tokens', async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + mockPrisma.user.create.mockResolvedValue({ + id: 'uuid-2', + email: 'oauth@user.com', + name: 'OAuth User', + provider: 'google', + createdAt: new Date(), + updatedAt: new Date(), + }); + mockRedis.set.mockResolvedValue('OK'); + + const res = await authService.oauthLogin('google', { email: 'oauth@user.com', name: 'OAuth User' }); + + expect(mockPrisma.user.create).toHaveBeenCalled(); + expect(res.user).toEqual({ id: 'uuid-2', email: 'oauth@user.com', name: 'OAuth User' }); + expect(res.tokens?.accessToken).toBeDefined(); + expect(res.tokens?.refreshToken).toBeDefined(); + expect(mockRedis.set).toHaveBeenCalledWith(expect.stringContaining('refresh:uuid-2:'), '1', 'EX', 604800); + }); + + it('should use existing user when found and return tokens', async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + id: 'uuid-3', + email: 'exist@user.com', + name: 'Existing', + }); + mockRedis.set.mockResolvedValue('OK'); + + const res = await authService.oauthLogin('google', { email: 'exist@user.com', name: 'Existing' }); + + expect(mockPrisma.user.create).not.toHaveBeenCalled(); + expect(res.user).toEqual({ id: 'uuid-3', email: 'exist@user.com', name: 'Existing' }); + expect(res.tokens?.accessToken).toBeDefined(); + expect(res.tokens?.refreshToken).toBeDefined(); + expect(mockRedis.set).toHaveBeenCalledWith(expect.stringContaining('refresh:uuid-3:'), '1', 'EX', 604800); + }); +}); diff --git a/src/__tests__/services/oauth.service.test.ts b/src/__tests__/services/oauth.service.test.ts new file mode 100644 index 0000000..e12088f --- /dev/null +++ b/src/__tests__/services/oauth.service.test.ts @@ -0,0 +1,113 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import { BadRequestError } from '@utils/errors'; +import { AUTH_MESSAGES } from '@constants/messages'; + +jest.mock('@config/env', () => ({ + env: { + NODE_ENV: 'test', + GOOGLE_CLIENT_ID: 'google-id', + GOOGLE_CLIENT_SECRET: 'google-secret', + GOOGLE_CALLBACK_URL: 'https://app.example.com/oauth/google/callback', + GITHUB_CLIENT_ID: 'github-id', + GITHUB_CLIENT_SECRET: 'github-secret', + GITHUB_CALLBACK_URL: 'https://app.example.com/oauth/github/callback', + }, +})); + +import { getAuthorizationUrl, exchangeCodeForProfile } from '@services/oauth.service'; + +describe('oauth.service', () => { + let fetchSpy: jest.SpyInstance; + + afterEach(() => { + if (fetchSpy) fetchSpy.mockRestore(); + }); + + test('getAuthorizationUrl builds google URL and includes access_type', () => { + const url = getAuthorizationUrl('google', 'state123'); + expect(url).toContain('accounts.google.com'); + expect(url).toContain('client_id=google-id'); + expect(url).toContain('access_type=offline'); + expect(url).toContain('prompt=consent'); + expect(url).toContain('state=state123'); + }); + + test('getAuthorizationUrl throws when provider not configured', () => { + jest.resetModules(); + jest.doMock('@config/env', () => ({ env: { GOOGLE_CLIENT_ID: '', GOOGLE_CLIENT_SECRET: '' } })); + const svc = require('@services/oauth.service'); + expect(() => svc.getAuthorizationUrl('google', 's')).toThrow(AUTH_MESSAGES.OAUTH_PROVIDER_NOT_CONFIGURED); + jest.resetModules(); + }); + + test('exchangeCodeForProfile throws when token endpoint returns non-ok', async () => { + fetchSpy = jest.spyOn(global as any, 'fetch').mockImplementation(async () => ({ ok: false })); + await expect(exchangeCodeForProfile('google', 'code')).rejects.toThrow(BadRequestError); + }); + + test('exchangeCodeForProfile throws when token response contains error', async () => { + fetchSpy = jest.spyOn(global as any, 'fetch').mockImplementationOnce(async () => ({ + ok: true, + json: async () => ({ error: 'bad' }), + })); + + await expect(exchangeCodeForProfile('google', 'code')).rejects.toThrow(BadRequestError); + }); + + test('exchangeCodeForProfile returns google profile on success', async () => { + fetchSpy = jest.spyOn(global as any, 'fetch') + .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ access_token: 'tok' }) })) + .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ email: 'a@example.com', name: 'Alice' }) })); + + const profile = await exchangeCodeForProfile('google', 'code'); + expect(profile.email).toBe('a@example.com'); + expect(profile.name).toBe('Alice'); + }); + + test('exchangeCodeForProfile returns github profile using emails endpoint when necessary', async () => { + fetchSpy = jest.spyOn(global as any, 'fetch') + .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ access_token: 'gh-token' }) })) + .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ email: null, login: 'octocat', name: null }) })) + .mockImplementationOnce(async () => ({ ok: true, json: async () => ([{ email: 'gh@example.com', primary: true, verified: true }]) })); + + const profile = await exchangeCodeForProfile('github', 'code'); + expect(profile.email).toBe('gh@example.com'); + expect(profile.name).toBe('octocat'); + }); + + test('exchangeCodeForProfile throws when google userinfo endpoint fails', async () => { + fetchSpy = jest.spyOn(global as any, 'fetch') + .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ access_token: 'tok' }) })) + .mockImplementationOnce(async () => ({ ok: false })); + + await expect(exchangeCodeForProfile('google', 'code')).rejects.toThrow(BadRequestError); + }); + + test('exchangeCodeForProfile throws when google userinfo missing email', async () => { + fetchSpy = jest.spyOn(global as any, 'fetch') + .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ access_token: 'tok' }) })) + .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ name: 'NoEmail' }) })); + + await expect(exchangeCodeForProfile('google', 'code')).rejects.toThrow(BadRequestError); + }); + + test('exchangeCodeForProfile throws when github user endpoint fails', async () => { + fetchSpy = jest.spyOn(global as any, 'fetch') + .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ access_token: 'gh-token' }) })) + .mockImplementationOnce(async () => ({ ok: false })); + + await expect(exchangeCodeForProfile('github', 'code')).rejects.toThrow(BadRequestError); + }); + + test('exchangeCodeForProfile throws when github emails endpoint not ok and no email found', async () => { + fetchSpy = jest.spyOn(global as any, 'fetch') + .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ access_token: 'gh-token' }) })) + .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ email: null, login: 'octocat', name: null }) })) + .mockImplementationOnce(async () => ({ ok: false })); + + await expect(exchangeCodeForProfile('github', 'code')).rejects.toThrow(BadRequestError); + }); +}); diff --git a/src/api/controllers/auth.controller.ts b/src/api/controllers/auth.controller.ts index e62ca07..0c18dd2 100644 --- a/src/api/controllers/auth.controller.ts +++ b/src/api/controllers/auth.controller.ts @@ -3,15 +3,21 @@ // See LICENSE file in the project root for full license information. import { Request, Response, NextFunction } from 'express'; +import crypto from 'crypto'; import * as authService from '@services/auth.service'; +import * as oauthService from '@services/oauth.service'; +import type { OAuthProvider } from 'types/auth'; import { AUTH_MESSAGES } from '@constants/messages'; +import { VALID_PROVIDERS } from '@constants/auth'; +import { StatusCodes } from 'http-status-codes'; +import { env } from '@config/env'; export async function signup(req: Request, res: Response, next: NextFunction): Promise { try { const { email, password, name } = req.body; const response = await authService.signup({ email, password, name }); - res.status(201).json(response); + res.status(StatusCodes.CREATED).json(response); } catch (err) { next(err); } @@ -21,7 +27,7 @@ export async function login(req: Request, res: Response, next: NextFunction): Pr try { const { email, password } = req.body; const response = await authService.login({ email, password }); - res.status(200).json(response); + res.status(StatusCodes.OK).json(response); } catch (err) { next(err); } @@ -31,7 +37,7 @@ export async function refresh(req: Request, res: Response, next: NextFunction): try { const { refreshToken } = req.body; const response = await authService.refresh(refreshToken); - res.status(200).json(response); + res.status(StatusCodes.OK).json(response); } catch (err) { next(err); } @@ -42,9 +48,9 @@ export async function logout(req: Request, res: Response, next: NextFunction): P const { refreshToken } = req.body; if (refreshToken) { const response = await authService.logout(refreshToken); - res.status(200).json(response); + res.status(StatusCodes.OK).json(response); } else { - res.status(200).json({ message: AUTH_MESSAGES.LOGOUT_SUCCESS }); + res.status(StatusCodes.OK).json({ message: AUTH_MESSAGES.LOGOUT_SUCCESS }); } } catch (err) { next(err); @@ -57,7 +63,7 @@ export async function forgotPassword(req: Request, res: Response, next: NextFunc authService.forgotPassword({ email }).catch((err) => { console.error('Error processing forgot-password:', err); }); - res.status(200).json({ message: AUTH_MESSAGES.FORGOT_PASSWORD_SENT }); + res.status(StatusCodes.OK).json({ message: AUTH_MESSAGES.FORGOT_PASSWORD_SENT }); } catch (err) { next(err); } @@ -67,8 +73,64 @@ export async function resetPassword(req: Request, res: Response, next: NextFunct try { const { token, newPassword } = req.body; const response = await authService.resetPassword({ token, newPassword }); - res.status(200).json(response); + res.status(StatusCodes.OK).json(response); } catch (err) { next(err); } } + +export async function oauthRedirect(req: Request, res: Response, next: NextFunction): Promise { + try { + const provider = req.params.provider as OAuthProvider; + if (!VALID_PROVIDERS.has(provider)) { + res.status(StatusCodes.BAD_REQUEST).json({ message: AUTH_MESSAGES.UNSUPPORTED_OAUTH_PROVIDER }); + return; + } + + const state = crypto.randomBytes(16).toString('hex'); + const url = oauthService.getAuthorizationUrl(provider, state); + res.redirect(url); + } catch (err) { + next(err); + } +} + +export async function oauthCallback(req: Request, res: Response, next: NextFunction): Promise { + try { + const provider = req.params.provider as OAuthProvider; + if (!VALID_PROVIDERS.has(provider)) { + res.status(StatusCodes.BAD_REQUEST).json({ message: AUTH_MESSAGES.UNSUPPORTED_OAUTH_PROVIDER }); + return; + } + + const code = req.query.code as string | undefined; + const oauthError = req.query.error as string | undefined; + + if (oauthError || !code) { + const msg = oauthError === 'access_denied' + ? AUTH_MESSAGES.OAUTH_CANCELLED + : AUTH_MESSAGES.OAUTH_CODE_MISSING; + const errorRedirect = `${env.OAUTH_FRONTEND_URL}/login?error=${encodeURIComponent(msg)}`; + res.redirect(errorRedirect); + return; + } + + const profile = await oauthService.exchangeCodeForProfile(provider, code); + const loginResponse = await authService.oauthLogin(provider, profile); + const { accessToken, refreshToken } = loginResponse.tokens!; + + const params = new URLSearchParams({ + accessToken, + refreshToken, + userId: loginResponse.user!.id, + email: loginResponse.user!.email, + name: loginResponse.user!.name, + }); + + res.redirect(`${env.OAUTH_FRONTEND_URL}/oauth/callback?${params.toString()}`); + } catch (err) { + const message = err instanceof Error ? err.message : 'OAuth login failed'; + const errorRedirect = `${env.OAUTH_FRONTEND_URL}/login?error=${encodeURIComponent(message)}`; + res.redirect(errorRedirect); + } +} diff --git a/src/api/middlewares/errorHandler.ts b/src/api/middlewares/errorHandler.ts index 3b02604..732b35f 100644 --- a/src/api/middlewares/errorHandler.ts +++ b/src/api/middlewares/errorHandler.ts @@ -4,6 +4,7 @@ import { Request, Response, NextFunction } from 'express'; import { AppError } from '@utils/errors'; +import { StatusCodes } from 'http-status-codes'; export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction): void { if (err instanceof AppError) { @@ -12,5 +13,5 @@ export function errorHandler(err: Error, _req: Request, res: Response, _next: Ne } console.error('Unhandled error:', err); - res.status(500).json({ message: 'Internal server error' }); + res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ message: 'Internal server error' }); } diff --git a/src/api/middlewares/validate.ts b/src/api/middlewares/validate.ts index a40dd00..517d48b 100644 --- a/src/api/middlewares/validate.ts +++ b/src/api/middlewares/validate.ts @@ -3,6 +3,7 @@ // See LICENSE file in the project root for full license information. import { Request, Response, NextFunction } from 'express'; +import { StatusCodes } from 'http-status-codes'; import { ZodType } from 'zod'; /** @@ -16,7 +17,7 @@ export function validateBody(schema: ZodType) { const result = schema.safeParse(req.body); if (!result.success) { const firstIssue = result.error.issues[0]; - res.status(400).json({ + res.status(StatusCodes.BAD_REQUEST).json({ error: 'Validation failed', message: firstIssue?.message ?? 'Invalid request body', }); diff --git a/src/api/routes/auth.routes.ts b/src/api/routes/auth.routes.ts index 0296220..ff211e0 100644 --- a/src/api/routes/auth.routes.ts +++ b/src/api/routes/auth.routes.ts @@ -22,4 +22,8 @@ router.post('/logout', authController.logout); router.post('/forgot-password', validateBody(ForgotPasswordRequestSchema), authController.forgotPassword); router.post('/reset-password', validateBody(ResetPasswordRequestSchema), authController.resetPassword); +// OAuth +router.get('/oauth/:provider', authController.oauthRedirect); +router.get('/oauth/:provider/callback', authController.oauthCallback); + export default router; diff --git a/src/config/env.ts b/src/config/env.ts index ea97a43..85ca2a5 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -13,4 +13,13 @@ export const env = { JWT_REFRESH_EXPIRY_SECONDS: parseInt(process.env.JWT_REFRESH_EXPIRY_SECONDS ?? '604800', 10), // 7 days RESET_TOKEN_TTL_SECONDS: parseInt(process.env.RESET_TOKEN_TTL_SECONDS ?? '900', 10), // 15 min API_PREFIX: process.env.API_PREFIX ?? '/api/v1', -} as const; + + // OAuth + OAUTH_FRONTEND_URL: process.env.OAUTH_FRONTEND_URL ?? 'http://localhost:5173', + GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID ?? '', + GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET ?? '', + GOOGLE_CALLBACK_URL: process.env.GOOGLE_CALLBACK_URL ?? 'http://localhost:3000/api/v1/auth/oauth/google/callback', + GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID ?? '', + GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET ?? '', + GITHUB_CALLBACK_URL: process.env.GITHUB_CALLBACK_URL ?? 'http://localhost:3000/api/v1/auth/oauth/github/callback', +} as const; \ No newline at end of file diff --git a/src/constants/auth.ts b/src/constants/auth.ts new file mode 100644 index 0000000..17daf86 --- /dev/null +++ b/src/constants/auth.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +// OAuth provider constants +// Central place for OAuth-related constants used across the API + +import type { OAuthProvider } from 'types/auth' + +export const VALID_PROVIDERS = new Set(['google', 'github']) diff --git a/src/constants/messages/auth.ts b/src/constants/messages/auth.ts index 503653b..6c45da5 100644 --- a/src/constants/messages/auth.ts +++ b/src/constants/messages/auth.ts @@ -26,6 +26,15 @@ export const AUTH_MESSAGES = { // reset-password RESET_PASSWORD_SUCCESS: 'Password reset successfully', RESET_TOKEN_INVALID: 'Invalid or expired reset token', + + // oauth + UNSUPPORTED_OAUTH_PROVIDER: 'Unsupported OAuth provider', + OAUTH_PROVIDER_NOT_CONFIGURED: 'OAuth provider is not configured', + OAUTH_CODE_MISSING: 'Authorization code missing from callback', + OAUTH_TOKEN_EXCHANGE_FAILED: 'Failed to exchange authorization code for tokens', + OAUTH_USER_INFO_FAILED: 'Failed to retrieve user info from provider', + OAUTH_EMAIL_MISSING: 'OAuth provider did not return an email address', + OAUTH_CANCELLED: 'OAuth flow was cancelled by the user', } as const; /** diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index a585dd1..05e3c66 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -21,6 +21,7 @@ import type { } from '@models/auth'; import type { MessageResponse } from '@models/common'; import { AUTH_MESSAGES } from '@constants/messages'; +import type { OAuthProvider } from 'types/auth'; export async function signup(input: SignupRequest): Promise { @@ -44,7 +45,7 @@ export async function signup(input: SignupRequest): Promise { export async function login(input: LoginRequest): Promise { const user = await prisma.user.findUnique({ where: { email: input.email } }); - if (!user) { + if (!user || !user.password) { throw new UnauthorizedError(AUTH_MESSAGES.INVALID_CREDENTIALS); } @@ -146,3 +147,36 @@ export async function resetPassword(input: ResetPasswordRequest): Promise { + let user = await prisma.user.findUnique({ where: { email: profile.email } }); + + if (!user) { + user = await prisma.user.create({ + data: { + email: profile.email, + name: profile.name, + provider, + }, + }); + } + + const accessToken = generateAccessToken(user.id); + const rawRefreshToken = generateRefreshToken(); + const hashedRefresh = hashToken(rawRefreshToken); + + await redis.set( + refreshKey(user.id, hashedRefresh), + '1', + 'EX', + env.JWT_REFRESH_EXPIRY_SECONDS, + ); + + return { + tokens: { accessToken, refreshToken: rawRefreshToken }, + user: { id: user.id, email: user.email, name: user.name }, + }; +} diff --git a/src/services/oauth.service.ts b/src/services/oauth.service.ts new file mode 100644 index 0000000..d10dc37 --- /dev/null +++ b/src/services/oauth.service.ts @@ -0,0 +1,150 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import { env } from '@config/env'; +import { BadRequestError } from '@utils/errors'; +import { AUTH_MESSAGES } from '@constants/messages'; +import type { OAuthProvider, OAuthUserProfile, OAuthProviderConfig } from 'types/auth'; + +function getProviderConfig(provider: OAuthProvider): OAuthProviderConfig { + switch (provider) { + case 'google': + return { + clientId: env.GOOGLE_CLIENT_ID, + clientSecret: env.GOOGLE_CLIENT_SECRET, + callbackUrl: env.GOOGLE_CALLBACK_URL, + authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenUrl: 'https://oauth2.googleapis.com/token', + scope: 'openid email profile', + fetchProfile: fetchGoogleProfile, + }; + case 'github': + return { + clientId: env.GITHUB_CLIENT_ID, + clientSecret: env.GITHUB_CLIENT_SECRET, + callbackUrl: env.GITHUB_CALLBACK_URL, + authorizeUrl: 'https://github.com/login/oauth/authorize', + tokenUrl: 'https://github.com/login/oauth/access_token', + scope: 'read:user user:email', + fetchProfile: fetchGitHubProfile, + }; + } +} + +export function getAuthorizationUrl(provider: OAuthProvider, state: string): string { + const config = getProviderConfig(provider); + + if (!config.clientId) { + throw new BadRequestError(AUTH_MESSAGES.OAUTH_PROVIDER_NOT_CONFIGURED); + } + + const params = new URLSearchParams({ + client_id: config.clientId, + redirect_uri: config.callbackUrl, + response_type: 'code', + scope: config.scope, + state, + }); + + if (provider === 'google') { + params.set('access_type', 'offline'); + params.set('prompt', 'consent'); + } + + return `${config.authorizeUrl}?${params.toString()}`; +} + +export async function exchangeCodeForProfile( + provider: OAuthProvider, + code: string, +): Promise { + const config = getProviderConfig(provider); + + const tokenBody: Record = { + client_id: config.clientId, + client_secret: config.clientSecret, + code, + redirect_uri: config.callbackUrl, + grant_type: 'authorization_code', + }; + + const tokenHeaders: Record = { + 'Content-Type': 'application/x-www-form-urlencoded', + }; + + if (provider === 'github') { + tokenHeaders['Accept'] = 'application/json'; + } + + const tokenRes = await fetch(config.tokenUrl, { + method: 'POST', + headers: tokenHeaders, + body: new URLSearchParams(tokenBody).toString(), + }); + + if (!tokenRes.ok) { + throw new BadRequestError(AUTH_MESSAGES.OAUTH_TOKEN_EXCHANGE_FAILED); + } + + const tokenData = (await tokenRes.json()) as Record; + + if (tokenData.error) { + throw new BadRequestError(AUTH_MESSAGES.OAUTH_TOKEN_EXCHANGE_FAILED); + } + + const accessToken: string = tokenData.access_token; + + if (!accessToken) { + throw new BadRequestError(AUTH_MESSAGES.OAUTH_TOKEN_EXCHANGE_FAILED); + } + + return config.fetchProfile(accessToken); +} + +async function fetchGoogleProfile(accessToken: string): Promise { + const res = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!res.ok) { + throw new BadRequestError(AUTH_MESSAGES.OAUTH_USER_INFO_FAILED); + } + + const data = (await res.json()) as Record; + if (!data.email) { + throw new BadRequestError(AUTH_MESSAGES.OAUTH_EMAIL_MISSING); + } + + return { email: data.email, name: data.name ?? data.email }; +} + +async function fetchGitHubProfile(accessToken: string): Promise { + const headers = { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github+json', + }; + + const userRes = await fetch('https://api.github.com/user', { headers }); + if (!userRes.ok) { + throw new BadRequestError(AUTH_MESSAGES.OAUTH_USER_INFO_FAILED); + } + const userData = (await userRes.json()) as Record; + + let email: string | null = userData.email ?? null; + + if (!email) { + const emailRes = await fetch('https://api.github.com/user/emails', { headers }); + if (emailRes.ok) { + const emails = (await emailRes.json()) as Array<{ email: string; primary: boolean; verified: boolean }>; + const primary = emails.find((e) => e.primary && e.verified); + email = primary?.email ?? emails[0]?.email ?? null; + } + } + + if (!email) { + throw new BadRequestError(AUTH_MESSAGES.OAUTH_EMAIL_MISSING); + } + + return { email, name: (userData.name ?? userData.login ?? email) as string }; +} diff --git a/src/types/auth.ts b/src/types/auth.ts new file mode 100644 index 0000000..bd69ae7 --- /dev/null +++ b/src/types/auth.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +export type OAuthProvider = 'google' | 'github'; + +export interface OAuthUserProfile { + email: string; + name: string; +} + +export interface OAuthProviderConfig { + clientId: string; + clientSecret: string; + callbackUrl: string; + authorizeUrl: string; + tokenUrl: string; + scope: string; + fetchProfile: (accessToken: string) => Promise; +}