From 7d7121b824d226524eb98c9cd582b1d499f8af71 Mon Sep 17 00:00:00 2001 From: Harxhit Date: Wed, 3 Jun 2026 13:36:47 +0530 Subject: [PATCH 1/8] refactor(auth): restructure user schema for enhanced authentication --- apps/backend/prisma/schema.prisma | 49 ++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 28458021..190f8231 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -18,25 +18,60 @@ model User { company String? avatarUrl String? @map("avatar_url") accentColor String @default("#6366f1") @map("accent_color") - provider String - providerId String @map("provider_id") + emailVerified Boolean @default(false) @map("email_verified") + phoneNumber String? @unique @map("phone_number") + lastSignInAt DateTime? @map("last_sign_in_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") + isActive Boolean @default(false) + identities UserIdentity[] + refreshTokens RefreshToken[] platformLinks PlatformLink[] cards Card[] oauthTokens OAuthToken[] ownedViews CardView[] @relation("cardOwner") viewedCards CardView[] @relation("cardViewer") followLogs FollowLog[] - organizer Event[] - attendedEvents EventAttendee[] + organizer Event[] + attendedEvents EventAttendee[] + ownedTeams Team[] @relation("TeamOwner") + teamMemberships TeamMember[] @relation("TeamMember") - ownedTeams Team[] @relation("TeamOwner") - teamMemberships TeamMember[] @relation("TeamMember") + @@map("users") +} + +model UserIdentity { + id String @id @default(uuid()) + userId String @map("user_id") + provider String // "google.com" | "apple.com" | "firebase" | "phone" + providerId String @map("provider_id") // Google sub / Apple sub / Firebase UID + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([provider, providerId]) - @@map("users") + @@index([userId]) + @@map("user_identities") +} + + +model RefreshToken { + id String @id @default(uuid()) + userId String @map("user_id") + tokenHash String @unique @map("token_hash") //SHA-256 hash + family String // token rotation + expiresAt DateTime @map("expires_at") + revokedAt DateTime? @map("revoked_at") // null = still valid + createdAt DateTime @default(now()) @map("created_at") + userAgent String? @map("user_agent") + ip String? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([family]) + @@map("refresh_tokens") } model PlatformLink { From 2fab9945ed78b7caa7d5375c9657201725af951f Mon Sep 17 00:00:00 2001 From: Harxhit Date: Wed, 3 Jun 2026 13:40:08 +0530 Subject: [PATCH 2/8] fix: Added inline comment --- apps/backend/prisma/schema.prisma | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 190f8231..48d4a03a 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -65,7 +65,7 @@ model RefreshToken { revokedAt DateTime? @map("revoked_at") // null = still valid createdAt DateTime @default(now()) @map("created_at") userAgent String? @map("user_agent") - ip String? + ip String? //hash user User @relation(fields: [userId], references: [id], onDelete: Cascade) From cc235564fafce0a1ddc6894f44930972ee31092d Mon Sep 17 00:00:00 2001 From: Harshit Date: Sat, 6 Jun 2026 17:14:31 +0530 Subject: [PATCH 3/8] fix: add role to schema Signed-off-by: Harshit --- apps/backend/prisma/schema.prisma | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 48d4a03a..6b7cda4b 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -6,7 +6,12 @@ datasource db { url = env("DATABASE_URL") } - +enum Role{ + SUPERADMIN + ADMIN + USER + +} model User { id String @id @default(uuid()) email String @unique @@ -14,7 +19,7 @@ model User { displayName String @map("display_name") bio String? pronouns String? - role String? + role Role @default(USER) company String? avatarUrl String? @map("avatar_url") accentColor String @default("#6366f1") @map("accent_color") From abe07a155061008f912766b7c7a7bcf8dda7764c Mon Sep 17 00:00:00 2001 From: Harshit Date: Wed, 10 Jun 2026 17:10:38 +0530 Subject: [PATCH 4/8] feat(auth): implement OAuth authentication flow --- apps/backend/prisma/schema.prisma | 3 +- apps/backend/src/routes/auth.ts | 481 +++++++++++++++++++++---- apps/backend/src/utils/error.util.ts | 38 ++ apps/backend/src/utils/jwt.ts | 18 +- apps/backend/src/utils/refreshToken.ts | 19 + 5 files changed, 479 insertions(+), 80 deletions(-) create mode 100644 apps/backend/src/utils/refreshToken.ts diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 6b7cda4b..38fb91fe 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -19,7 +19,8 @@ model User { displayName String @map("display_name") bio String? pronouns String? - role Role @default(USER) + role String? + authRole Role @default(USER) company String? avatarUrl String? @map("avatar_url") accentColor String @default("#6366f1") @map("accent_color") diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index cffebea7..a2185fdc 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -1,9 +1,18 @@ import { encrypt } from '../utils/encryption.js'; -import { extractRawJwt, blocklistKey } from '../utils/jwt.js'; +import { handleDbError, isGitHubTokenError, isGoogleTokenError } from '../utils/error.util.js'; +import { extractRawJwt, blocklistKey, signAccessToken } from '../utils/jwt.js'; import { buildOAuthState, getMobileRedirectUri } from '../utils/oauth.js'; +import { generateRefreshToken, hashIp, hashRefreshToken } from '../utils/refreshToken.js'; +import type { GitHubTokenErrorResponse, GitHubTokenResponse } from '../utils/error.util.js'; import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +interface GitHubEmailResponse { + email: string; + primary: boolean; + verified: boolean; +} + 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'; @@ -16,7 +25,35 @@ interface OAuthCallbackQuery { state?: string; } -export async function authRoutes(app: FastifyInstance): Promise { +type GoogleAuthQuery = { + state?: string; + mobile_redirect_uri?: string; +}; + +interface GoogleUser { + id: string; + email: string; + name: string; + picture?: string; +} + +interface GoogleTokenResponse { + access_token: string; + refresh_token?: string; + expires_in: number; + token_type: string; +} + +interface GitHubUserResponse { + id: number; + login: string; + name: string | null; + email: string | null; + avatar_url: string; +} + + +export async function authRoutes(app: FastifyInstance) { // Developer login bypass (development only) if (process.env.NODE_ENV !== 'production') { app.post('/dev-login', async (request: FastifyRequest, reply: FastifyReply) => { @@ -30,10 +67,23 @@ export async function authRoutes(app: FastifyInstance): Promise { } // GitHub OAuth start - app.get('/github', async (request: FastifyRequest, reply: FastifyReply) => { + app.get('/github', async (request: FastifyRequest<{Querystring: GoogleAuthQuery}>, reply: FastifyReply) => { + const clientId = process.env.GITHUB_CLIENT_ID; + if(!clientId){ + return reply.status(400).send() + } + //Need to add zod validation here too + 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 clientState = (request.query as any).state || ''; - const mobileRedirectUri = (request.query as any).mobile_redirect_uri || ''; const state = buildOAuthState(clientState, mobileRedirectUri); reply.setCookie('oauth_state', state, { @@ -45,7 +95,7 @@ export async function authRoutes(app: FastifyInstance): Promise { }); const params = new URLSearchParams({ - client_id: (process.env.GITHUB_CLIENT_ID || '').trim(), + client_id: clientId, redirect_uri: redirectUri, scope: 'read:user user:email', state, @@ -81,70 +131,137 @@ export async function authRoutes(app: FastifyInstance): Promise { }), }); - const tokenData = (await tokenRes.json()) as any; - if (tokenData.error) { - app.log.error({ tokenData }, 'GitHub token error'); - return reply.status(400).send({ error: 'Failed to authenticate with GitHub' }); - } + const tokenData = (await tokenRes.json()) as + GitHubTokenResponse | GitHubTokenErrorResponse; + + if (!tokenRes.ok || isGitHubTokenError(tokenData)) { + app.log.error( + { tokenData, status: tokenRes.status }, + 'GitHub token exchange failed', + ); + + return reply.status(400).send({ + error: 'Failed to authenticate with GitHub', + }); + } const userRes = await fetch(GITHUB_USER_URL, { headers: { Authorization: `Bearer ${tokenData.access_token}` } }); - const githubUser = (await userRes.json()) as any; + const githubUser = (await userRes.json()) as GitHubUserResponse;; let email = githubUser.email; if (!email) { const emailsRes = await fetch('https://api.github.com/user/emails', { headers: { Authorization: `Bearer ${tokenData.access_token}` }, }); - const emails = (await emailsRes.json()) as any[]; - const primary = emails.find((e: any) => e.primary && e.verified); - email = primary?.email || emails[0]?.email; - } + const emails = (await emailsRes.json()) as GitHubEmailResponse[]; + const primary = emails.find( + (e) => e.primary && e.verified, + ); - const user = await app.prisma.user.upsert({ - where: { provider_providerId: { provider: 'github', providerId: String(githubUser.id) } }, - update: { - email: email || `${githubUser.login}@github.local`, - displayName: githubUser.name || githubUser.login, - avatarUrl: githubUser.avatar_url, - }, - create: { - email: email || `${githubUser.login}@github.local`, - username: githubUser.login, - displayName: githubUser.name || githubUser.login, - bio: githubUser.bio, - company: githubUser.company, - avatarUrl: githubUser.avatar_url, - provider: 'github', - providerId: String(githubUser.id), - }, - }); + email = primary?.email ?? null; + } - try { - const encryptedToken = encrypt(tokenData.access_token); - await app.prisma.oAuthToken.upsert({ - where: { userId_platform: { userId: user.id, platform: 'github' } }, - update: { accessToken: encryptedToken, scopes: 'read:user user:email' }, - create: { userId: user.id, platform: 'github', accessToken: encryptedToken, scopes: 'read:user user:email' }, + if (!email) { + return reply.status(400).send({ + error: 'No email returned by GitHub', }); - } catch (err) { - app.log.error({ err, userId: user.id }, 'Failed to persist GitHub OAuth token — authentication proceeds'); } - const token = app.jwt.sign({ id: user.id, username: user.username }, { expiresIn: '30d' }); + const baseUsername = email.split('@')[0].replace(/[^a-zA-Z0-9_-]/g, ''); + + const identity = await app.prisma.userIdentity.findUnique({ + where: { + provider_providerId: { + provider: 'github', + providerId: githubUser.id.toString() + }, + }, + include: { + user: true + } + }) + + let user; + + if (identity) { + user = await app.prisma.user.update({ + where: { + id: identity.user.id, + }, + data: { + email, + displayName: githubUser.name || baseUsername, + avatarUrl: githubUser.avatar_url, + lastSignInAt: new Date(), + isActive: true + }, + }); + }else{ + user = await app.prisma.user.create({ + data: { + email, + username: `${baseUsername}_${Date.now().toString(36)}`, + displayName: githubUser.name || baseUsername, + avatarUrl: githubUser.avatar_url, + emailVerified: true, + isActive: true, + lastSignInAt: new Date(), + + identities: { + create: { + provider: 'github', + providerId: githubUser.id.toString() + } + } + } + }) + } + + const accessToken = signAccessToken(app, user) + const refreshToken = generateRefreshToken() + const refreshTokenHash = hashRefreshToken(refreshToken); + const ip = hashIp(request.ip) + const userAgent = request.headers['user-agent'] ?? 'unknown'; + + await app.prisma.refreshToken.create({ + data: { + userId: user.id, + tokenHash: refreshTokenHash, + family: crypto.randomUUID(), + expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), + ip, + userAgent + } + }) if (request.query.state?.startsWith('mobile_')) { - const mobileRedirect = getMobileRedirectUri(request.query.state) || process.env.MOBILE_REDIRECT_URI; - return reply.redirect(`${mobileRedirect}#token=${token}`); + const exchangeCode = crypto.randomUUID(); + await app.redis.set( + `mobile_exchange:${exchangeCode}`, + JSON.stringify({ accessToken, refreshToken }), + 'EX', 60 + ); + const mobileRedirect = getMobileRedirectUri(request.query.state) + || process.env.MOBILE_REDIRECT_URI; + return reply.redirect(`${mobileRedirect}?code=${exchangeCode}`); } - reply.setCookie('token', token, { + reply.setCookie('access_Token', accessToken,{ httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', - maxAge: 30 * 24 * 60 * 60, + maxAge: 15 * 60, }); + reply.setCookie('refresh_token', refreshToken,{ + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 90 * 24 * 60 * 60, + }, + ); return reply.redirect(`${process.env.PUBLIC_APP_URL}/dashboard`); } catch (error) { app.log.error({ error }, 'GitHub auth error'); @@ -153,12 +270,26 @@ export async function authRoutes(app: FastifyInstance): Promise { }); // Google OAuth start - app.get('/google', async (request: FastifyRequest, reply: FastifyReply) => { + app.get('/google', async (request: FastifyRequest<{Querystring: GoogleAuthQuery}>, 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`; - const clientState = (request.query as any).state || ''; - const mobileRedirectUri = (request.query as any).mobile_redirect_uri || ''; + //Need to add zod validation here too + 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 state = buildOAuthState(clientState, mobileRedirectUri); - + reply.setCookie('oauth_state', state, { httpOnly: true, secure: process.env.NODE_ENV === 'production', @@ -166,9 +297,8 @@ export async function authRoutes(app: FastifyInstance): Promise { path: '/', maxAge: 10 * 60, }); - const params = new URLSearchParams({ - client_id: (process.env.GOOGLE_CLIENT_ID || '').trim(), + client_id: clientId, redirect_uri: redirectUri, response_type: 'code', scope: 'openid email profile', @@ -183,6 +313,7 @@ export async function authRoutes(app: FastifyInstance): Promise { // Google callback app.get('/google/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { + //Need to add zod validation const { code, state } = request.query; const storedState = request.cookies?.oauth_state; @@ -208,52 +339,238 @@ export async function authRoutes(app: FastifyInstance): Promise { }), }); - const tokenData = (await tokenRes.json()) as any; - if (tokenData.error) { - app.log.error({ tokenData }, 'Google token error'); + const tokenData = (await tokenRes.json()) as GoogleTokenResponse + if (!tokenRes.ok || isGoogleTokenError(tokenData)) { + app.log.error({ tokenData, status: tokenRes.status }, 'Google token exchange failed'); return reply.status(400).send({ error: 'Failed to authenticate with Google' }); } const userRes = await fetch(GOOGLE_USER_URL, { headers: { Authorization: `Bearer ${tokenData.access_token}` } }); - const googleUser = (await userRes.json()) as any; + const googleUser = (await userRes.json()) as GoogleUser; const baseUsername = googleUser.email.split('@')[0].replace(/[^a-zA-Z0-9_-]/g, ''); - const user = await app.prisma.user.upsert({ - where: { provider_providerId: { provider: 'google', providerId: googleUser.id } }, - update: { email: googleUser.email, displayName: googleUser.name || baseUsername, avatarUrl: googleUser.picture }, - create: { - email: googleUser.email, - username: `${baseUsername}_${Date.now().toString(36)}`, - displayName: googleUser.name || baseUsername, - avatarUrl: googleUser.picture, - provider: 'google', - providerId: googleUser.id, + const identity = await app.prisma.userIdentity.findUnique({ + where: { + provider_providerId: { + provider: 'google', + providerId: googleUser.id + }, }, - }); - - const token = app.jwt.sign({ id: user.id, username: user.username }, { expiresIn: '30d' }); + include: { + user: true + } + }) + + let user; + + if (identity) { + user = await app.prisma.user.update({ + where: { + id: identity.user.id, + }, + data: { + email: googleUser.email, + displayName: googleUser.name || baseUsername, + avatarUrl: googleUser.picture, + lastSignInAt: new Date(), + isActive: true + }, + }); + }else{ + user = await app.prisma.user.create({ + data: { + email: googleUser.email, + username: `${baseUsername}_${Date.now().toString(36)}`, + displayName: googleUser.name || baseUsername, + avatarUrl: googleUser.picture, + emailVerified: true, + isActive: true, + lastSignInAt: new Date(), + + identities: { + create: { + provider: 'google', + providerId: googleUser.id + } + } + } + }) + } + + const accessToken = signAccessToken(app, user) + const refreshToken = generateRefreshToken() + const refreshTokenHash = hashRefreshToken(refreshToken); + const ip = hashIp(request.ip) + const userAgent = request.headers['user-agent'] ?? 'unknown'; + + await app.prisma.refreshToken.create({ + data: { + userId: user.id, + tokenHash: refreshTokenHash, + family: crypto.randomUUID(), + expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), + ip, + userAgent + } + }) if (request.query.state?.startsWith('mobile_')) { - const mobileRedirect = getMobileRedirectUri(request.query.state) || process.env.MOBILE_REDIRECT_URI; - return reply.redirect(`${mobileRedirect}#token=${token}`); + const exchangeCode = crypto.randomUUID(); + await app.redis.set( + `mobile_exchange:${exchangeCode}`, + JSON.stringify({ accessToken, refreshToken }), + 'EX', 60 + ); + const mobileRedirect = getMobileRedirectUri(request.query.state) + || process.env.MOBILE_REDIRECT_URI; + return reply.redirect(`${mobileRedirect}?code=${exchangeCode}`); } - reply.setCookie('token', token, { + reply.setCookie('access_Token', accessToken,{ httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', - maxAge: 30 * 24 * 60 * 60, + maxAge: 15 * 60, }); + reply.setCookie('refresh_token', refreshToken,{ + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 90 * 24 * 60 * 60, + }, + ); + + app.log.info({ + user: user.id, + provider: 'google' + }, 'User is authenticated'); + return reply.redirect(`${process.env.PUBLIC_APP_URL}/dashboard`); } catch (error) { + handleDbError(error, request, reply) app.log.error({ error }, 'Google auth error'); return reply.status(500).send({ error: 'Authentication failed' }); } }); + app.post('/refresh', async(request: FastifyRequest, reply: FastifyReply) => { + const refreshToken = request.cookies.refresh_token ?? (request.body as { refresh_token?: string })?.refresh_token; + + if (!refreshToken) { + return reply.status(401).send({ + error: 'Refresh token missing', + }); + } + const tokenHash = hashRefreshToken(refreshToken); + + try { + + const storedToken = await app.prisma.refreshToken.findUnique({ + where: { + tokenHash + }, + include: { + user: true + } + }) + + if (!storedToken) { + return reply.status(401).send({ + error: 'Invalid refresh token', + }); + } + + if (storedToken.revokedAt) { + return reply.status(401).send({ + error: 'Refresh token revoked', + }); + } + + if(storedToken.expiresAt < new Date()){ + return reply.status(401).send({ + error: 'Refresh token expired', + }); + } + + await app.prisma.refreshToken.update({ + where: { + id: storedToken.id, + }, + data: { + revokedAt: new Date(), + }, + }); + + const newRefreshToken = generateRefreshToken(); + const newTokenHash = hashRefreshToken(newRefreshToken); + const ip = hashIp(request.ip) + const userAgent = request.headers['user-agent'] ?? 'unknown'; + + const details = { + id: storedToken.user.id, + username: storedToken.user.username + } + + await app.prisma.refreshToken.create({ + data: { + userId: storedToken.user.id, + tokenHash: newTokenHash, + family: storedToken.family, + expiresAt: new Date( + Date.now() + 90 * 24 * 60 * 60 * 1000, + ), + userAgent, + ip, + }, + }); + + + const accessToken = signAccessToken(app,details) + + const isMobileRequest = !request.cookies.refresh_token; + if (isMobileRequest) { + return reply.status(200).send({ accessToken, refreshToken: newRefreshToken }); + } + + reply.setCookie('access_Token', accessToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 15 * 60, + }); + + reply.setCookie('refresh_token',newRefreshToken,{ + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 90 * 24 * 60 * 60, + }, + ); + + return reply.status(200).send('Token revoked') + + } catch (error) { + handleDbError(error, request, reply) + app.log.error(error) + } + + }) + + app.post('/mobile/exchange', async (request: FastifyRequest<{Body: {code: string}}>, reply: FastifyReply) => { + const { code } = request.body; + const raw = await app.redis.getdel(`mobile_exchange:${code}`); + if (!raw) {return reply.status(400).send({ error: 'Invalid or expired exchange code' });} + + const { accessToken, refreshToken } = JSON.parse(raw); + return { accessToken, refreshToken }; + }); + // Current user app.get('/me', { // eslint-disable-next-line @typescript-eslint/unbound-method @@ -290,7 +607,7 @@ export async function authRoutes(app: FastifyInstance): Promise { // Cookie-only logout — use DELETE /auth/logout for token revocation. app.post('/logout', async (_request: FastifyRequest, reply: FastifyReply) => { app.log.info('Legacy cookie-only logout called — token not blocklisted'); - reply.clearCookie('token', { path: '/' }); + reply.clearCookie('access_Token', { path: '/' }); return { message: 'Logged out' }; }); @@ -337,8 +654,18 @@ export async function authRoutes(app: FastifyInstance): Promise { } } - reply.clearCookie('token', { path: '/' }); - return { message: 'Logged out' }; + reply.clearCookie('access_Token', { path: '/' }); + reply.clearCookie('refresh_token', { path: '/' }); + + const refreshToken = request.cookies.refresh_token ?? (request.body as { refresh_token?: string })?.refresh_token; + if (refreshToken) { + const hash = hashRefreshToken(refreshToken); + await app.prisma.refreshToken.updateMany({ + where: { tokenHash: hash }, + data: { revokedAt: new Date() }, + }); + return { message: 'Logged out' }; + } }); } diff --git a/apps/backend/src/utils/error.util.ts b/apps/backend/src/utils/error.util.ts index fef1b98b..db30806c 100644 --- a/apps/backend/src/utils/error.util.ts +++ b/apps/backend/src/utils/error.util.ts @@ -1,6 +1,32 @@ import type { FastifyReply, FastifyRequest } from 'fastify'; import { Prisma } from '@prisma/client'; +interface GoogleTokenResponse { + access_token: string; + refresh_token?: string; + expires_in: number; + token_type: string; + scope?: string; +} + +interface GoogleTokenErrorResponse { + error: string; + error_description?: string; +} + +export interface GitHubTokenResponse { + access_token: string; + token_type: string; + scope: string; +} + +export interface GitHubTokenErrorResponse { + error: string; + error_description?: string; +} + + + export function getErrorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } @@ -29,4 +55,16 @@ export function handleDbError(error: unknown, request: FastifyRequest, reply: Fa } return reply.status(500).send({ error: 'Internal Server Error' }); +} + +export function isGoogleTokenError( + data: GoogleTokenResponse | GoogleTokenErrorResponse, +): data is GoogleTokenErrorResponse { + return 'error' in data; +} + +export function isGitHubTokenError( + data: GitHubTokenResponse | GitHubTokenErrorResponse, +): data is GitHubTokenErrorResponse { + return 'error' in data; } \ No newline at end of file diff --git a/apps/backend/src/utils/jwt.ts b/apps/backend/src/utils/jwt.ts index 40386962..cc3b362b 100644 --- a/apps/backend/src/utils/jwt.ts +++ b/apps/backend/src/utils/jwt.ts @@ -1,6 +1,20 @@ import { createHash } from 'node:crypto'; -import type { FastifyRequest } from 'fastify'; +import type { FastifyInstance, FastifyRequest } from 'fastify'; + + + +export function signAccessToken(app: FastifyInstance, user: {id:string, username:string}){ + return app.jwt.sign( + { + id: user.id, + username: user.username + },{ + expiresIn: '15m' + } + +) +} /** * Extract the raw JWT string from a Fastify request. @@ -10,7 +24,7 @@ import type { FastifyRequest } from 'fastify'; export function extractRawJwt(request: FastifyRequest): string | null { const auth = request.headers.authorization; if (auth?.startsWith('Bearer ')) { return auth.slice(7) || null; } - return request.cookies?.token || null; + return request.cookies?.access_Token || null; } /** diff --git a/apps/backend/src/utils/refreshToken.ts b/apps/backend/src/utils/refreshToken.ts new file mode 100644 index 00000000..ade35986 --- /dev/null +++ b/apps/backend/src/utils/refreshToken.ts @@ -0,0 +1,19 @@ +import crypto from 'crypto'; + +export function generateRefreshToken() { + return crypto.randomBytes(64).toString('hex'); +} + +export function hashRefreshToken(token: string) { + return crypto + .createHash('sha256') + .update(token) + .digest('hex'); +} + +export function hashIp(ip: string): string { + return crypto + .createHash('sha256') + .update(ip) + .digest('hex'); +} \ No newline at end of file From 67b2e341f379f4380ecaaf18abfd55d9b6c42132 Mon Sep 17 00:00:00 2001 From: Harshit Date: Fri, 12 Jun 2026 00:25:04 +0530 Subject: [PATCH 5/8] fix(auth): add account linking logic and resolve lint issues --- apps/backend/src/routes/auth.ts | 108 +++++++++++++++++-------- apps/backend/src/utils/error.util.ts | 5 +- apps/backend/src/utils/jwt.ts | 17 ++-- apps/backend/src/utils/refreshToken.ts | 6 +- 4 files changed, 87 insertions(+), 49 deletions(-) diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index a2185fdc..e40d2e58 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -72,7 +72,7 @@ export async function authRoutes(app: FastifyInstance) { if(!clientId){ return reply.status(400).send() } - //Need to add zod validation here too + //TODO: Add zod validation here const { state: clientState = '', mobile_redirect_uri: mobileRedirectUri = '' } = request.query if ( @@ -108,6 +108,7 @@ export async function authRoutes(app: FastifyInstance) { // GitHub OAuth callback app.get('/github/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { + //TODO: Add zod validation here const { code, state } = request.query; const storedState = request.cookies?.oauth_state; if (!state || !storedState || state !== storedState) { @@ -197,24 +198,42 @@ export async function authRoutes(app: FastifyInstance) { }, }); }else{ - user = await app.prisma.user.create({ - data: { - email, - username: `${baseUsername}_${Date.now().toString(36)}`, - displayName: githubUser.name || baseUsername, - avatarUrl: githubUser.avatar_url, - emailVerified: true, - isActive: true, - lastSignInAt: new Date(), - - identities: { - create: { - provider: 'github', - providerId: githubUser.id.toString() - } - } + + const existingAccount = await app.prisma.user.findUnique({ + where: { + email: email } }) + + if(existingAccount){ + await app.prisma.userIdentity.create({ + data: { + userId: existingAccount.id, + provider: 'github', + providerId: githubUser.id.toString() + } + }) + user = existingAccount; + }else{ + user = await app.prisma.user.create({ + data: { + email, + username: `${baseUsername}_${Date.now().toString(36)}`, + displayName: githubUser.name || baseUsername, + avatarUrl: githubUser.avatar_url, + emailVerified: true, + isActive: true, + lastSignInAt: new Date(), + + identities: { + create: { + provider: 'github', + providerId: githubUser.id.toString() + } + } + } + }) + } } const accessToken = signAccessToken(app, user) @@ -276,7 +295,7 @@ export async function authRoutes(app: FastifyInstance) { return reply.status(400).send() } const redirectUri = `${process.env.BACKEND_URL}/auth/google/callback`; - //Need to add zod validation here too + //TODO: Add zod validation here const { state: clientState = '', mobile_redirect_uri: mobileRedirectUri = '' } = request.query if ( @@ -313,7 +332,7 @@ export async function authRoutes(app: FastifyInstance) { // Google callback app.get('/google/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { - //Need to add zod validation + //TODO: Add zod validation here const { code, state } = request.query; const storedState = request.cookies?.oauth_state; @@ -378,24 +397,43 @@ export async function authRoutes(app: FastifyInstance) { }, }); }else{ - user = await app.prisma.user.create({ - data: { - email: googleUser.email, - username: `${baseUsername}_${Date.now().toString(36)}`, - displayName: googleUser.name || baseUsername, - avatarUrl: googleUser.picture, - emailVerified: true, - isActive: true, - lastSignInAt: new Date(), - - identities: { - create: { - provider: 'google', - providerId: googleUser.id - } - } + const existingAccount = await app.prisma.user.findUnique({ + where: { + email: googleUser.email } }) + + if(existingAccount){ + await app.prisma.userIdentity.create({ + data: { + userId: existingAccount.id, + provider: 'google', + providerId: googleUser.id + } + }) + + user = existingAccount + }else{ + user = await app.prisma.user.create({ + data: { + email: googleUser.email, + username: `${baseUsername}_${Date.now().toString(36)}`, + displayName: googleUser.name || baseUsername, + avatarUrl: googleUser.picture, + emailVerified: true, + isActive: true, + lastSignInAt: new Date(), + + identities: { + create: { + provider: 'google', + providerId: googleUser.id + } + } + } + }) + + } } const accessToken = signAccessToken(app, user) diff --git a/apps/backend/src/utils/error.util.ts b/apps/backend/src/utils/error.util.ts index db30806c..d429f1fb 100644 --- a/apps/backend/src/utils/error.util.ts +++ b/apps/backend/src/utils/error.util.ts @@ -1,6 +1,7 @@ -import type { FastifyReply, FastifyRequest } from 'fastify'; import { Prisma } from '@prisma/client'; +import type { FastifyReply, FastifyRequest } from 'fastify'; + interface GoogleTokenResponse { access_token: string; refresh_token?: string; @@ -31,7 +32,7 @@ export function getErrorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } -export function handleDbError(error: unknown, request: FastifyRequest, reply: FastifyReply) { +export function handleDbError(error: unknown, request: FastifyRequest, reply: FastifyReply): FastifyReply { request.log.error(error); if (error instanceof Prisma.PrismaClientKnownRequestError) { diff --git a/apps/backend/src/utils/jwt.ts b/apps/backend/src/utils/jwt.ts index cc3b362b..de026333 100644 --- a/apps/backend/src/utils/jwt.ts +++ b/apps/backend/src/utils/jwt.ts @@ -4,16 +4,15 @@ import type { FastifyInstance, FastifyRequest } from 'fastify'; -export function signAccessToken(app: FastifyInstance, user: {id:string, username:string}){ +export function signAccessToken(app: FastifyInstance, user: {id:string, username:string}):string{ return app.jwt.sign( - { - id: user.id, - username: user.username - },{ - expiresIn: '15m' - } - -) + { + id: user.id, + username: user.username + },{ + expiresIn: '15m' + } + ) } /** diff --git a/apps/backend/src/utils/refreshToken.ts b/apps/backend/src/utils/refreshToken.ts index ade35986..227ff0ad 100644 --- a/apps/backend/src/utils/refreshToken.ts +++ b/apps/backend/src/utils/refreshToken.ts @@ -1,10 +1,10 @@ -import crypto from 'crypto'; +import crypto from 'node:crypto'; -export function generateRefreshToken() { +export function generateRefreshToken():string { return crypto.randomBytes(64).toString('hex'); } -export function hashRefreshToken(token: string) { +export function hashRefreshToken(token: string):string { return crypto .createHash('sha256') .update(token) From bc2bd0afec048a24578ea0dfc314ef9dc205a07c Mon Sep 17 00:00:00 2001 From: Harshit Date: Fri, 12 Jun 2026 00:46:43 +0530 Subject: [PATCH 6/8] test(auth): update logout tests for access token cookies --- apps/backend/src/__tests__/logout.test.ts | 22 +++++++++++----------- apps/backend/src/routes/auth.ts | 7 ++++--- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/apps/backend/src/__tests__/logout.test.ts b/apps/backend/src/__tests__/logout.test.ts index 15fc7d1f..684cd281 100644 --- a/apps/backend/src/__tests__/logout.test.ts +++ b/apps/backend/src/__tests__/logout.test.ts @@ -46,7 +46,7 @@ async function buildTestApp(mockRedis: MockRedis): Promise { // in app.ts so that both Authorization header and token cookie are accepted. await app.register(jwtPlugin as any, { secret: TEST_JWT_SECRET, - cookie: { cookieName: 'token', signed: false }, + cookie: { cookieName: 'access_Token', signed: false }, }); // Minimal Prisma stub. The logout route does not touch the database, but @@ -264,7 +264,7 @@ describe('DELETE /auth/logout', () => { const res = await app.inject({ method: 'DELETE', url: '/auth/logout', - headers: { Cookie: `token=${token}` }, + headers: { Cookie: `access_Token=${token}` }, }); expect(res.statusCode).toBe(200); @@ -284,7 +284,7 @@ describe('DELETE /auth/logout', () => { url: '/auth/logout', headers: { Authorization: `Bearer ${headerToken}`, - Cookie: `token=${cookieToken}`, + Cookie: `access_Token=${cookieToken}`, }, }); @@ -309,7 +309,7 @@ describe('DELETE /auth/logout', () => { const raw = res.headers['set-cookie'] as string | string[]; const cookieStr = Array.isArray(raw) ? raw.join('; ') : (raw ?? ''); // Value must be emptied. - expect(cookieStr).toMatch(/token=;/); + expect(cookieStr).toMatch(/access_Token=;/); // Path must be explicit so the browser clears the cookie on all routes. expect(cookieStr).toMatch(/Path=\//i); // Browser must be told to delete the cookie immediately. @@ -350,7 +350,7 @@ describe('DELETE /auth/logout', () => { expect(mockRedis.set).not.toHaveBeenCalled(); expect(warnMock).toHaveBeenCalledOnce(); // Verify the message identifies the root cause clearly. - const [, message] = warnMock.mock.calls[0] as [unknown, string]; + const [message] = warnMock.mock.calls[0] as [string]; expect(message).toMatch(/missing exp/i); }); @@ -471,7 +471,7 @@ describe('authenticate middleware', () => { const res = await app.inject({ method: 'GET', url: '/protected', - headers: { Cookie: `token=${token}` }, + headers: { Cookie: `access_Token=${token}` }, }); expect(res.statusCode).toBe(200); @@ -574,7 +574,7 @@ describe('revocation flow — end-to-end', () => { const logout = await app.inject({ method: 'DELETE', url: '/auth/logout', - headers: { Cookie: `token=${token}` }, + headers: { Cookie: `access_Token=${token}` }, }); expect(logout.statusCode).toBe(200); expect(mockRedis.set).toHaveBeenCalledOnce(); @@ -588,7 +588,7 @@ describe('revocation flow — end-to-end', () => { const after = await app.inject({ method: 'GET', url: '/protected', - headers: { Cookie: `token=${token}` }, + headers: { Cookie: `access_Token=${token}` }, }); expect(after.statusCode).toBe(401); expect(after.json().error).toBe('Token has been revoked'); @@ -637,14 +637,14 @@ describe('extractRawJwt', () => { }); it('returns token from cookie when no Authorization header', () => { - const req = makeRequest({ cookies: { token: 'cookie.jwt.token' } }); + const req = makeRequest({ cookies: { access_Token: 'cookie.jwt.token' } }); expect(extractRawJwt(req)).toBe('cookie.jwt.token'); }); it('prefers Authorization header over cookie', () => { const req = makeRequest({ authorization: 'Bearer header.jwt.token', - cookies: { token: 'cookie.jwt.token' }, + cookies: { access_Token: 'cookie.jwt.token' }, }); expect(extractRawJwt(req)).toBe('header.jwt.token'); }); @@ -666,7 +666,7 @@ describe('extractRawJwt', () => { }); it('returns null when the token cookie value is empty', () => { - const req = makeRequest({ cookies: { token: '' } }); + const req = makeRequest({ cookies: { access_Token: '' } }); // || null normalises the empty string to null, matching the return type. expect(extractRawJwt(req)).toBeNull(); }); diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index e40d2e58..d56add32 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -646,7 +646,7 @@ export async function authRoutes(app: FastifyInstance) { app.post('/logout', async (_request: FastifyRequest, reply: FastifyReply) => { app.log.info('Legacy cookie-only logout called — token not blocklisted'); reply.clearCookie('access_Token', { path: '/' }); - return { message: 'Logged out' }; + return reply.status(200).send({message: 'Logged out',}); }); // ─── Secure Logout — blocklists the token in Redis ─── @@ -686,7 +686,6 @@ export async function authRoutes(app: FastifyInstance) { // server (we always pass expiresIn), but log a warning so it is // visible if a custom or third-party token ever reaches this path. app.log.warn( - { userId: (request.user as any)?.id }, 'JWT missing exp claim — skipping Redis blocklist; token cannot be actively revoked', ); } @@ -702,8 +701,10 @@ export async function authRoutes(app: FastifyInstance) { where: { tokenHash: hash }, data: { revokedAt: new Date() }, }); - return { message: 'Logged out' }; + return reply.status(200).send({message: 'Logged out',}); } + + return reply.status(200).send({ message: 'Logged out' }); }); } From 134a3787066c6c339b5d1b254d79e67c5d2976e8 Mon Sep 17 00:00:00 2001 From: Harshit Date: Fri, 12 Jun 2026 00:47:30 +0530 Subject: [PATCH 7/8] fix: Updated test file --- apps/backend/src/__tests__/logout.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/__tests__/logout.test.ts b/apps/backend/src/__tests__/logout.test.ts index 684cd281..fbfa685b 100644 --- a/apps/backend/src/__tests__/logout.test.ts +++ b/apps/backend/src/__tests__/logout.test.ts @@ -588,7 +588,7 @@ describe('revocation flow — end-to-end', () => { const after = await app.inject({ method: 'GET', url: '/protected', - headers: { Cookie: `access_Token=${token}` }, + headers: { Cookie: `access_Token=${token}` }, }); expect(after.statusCode).toBe(401); expect(after.json().error).toBe('Token has been revoked'); From d249eeafd5a4d78787a6be8d8ff9b5f0464b6bed Mon Sep 17 00:00:00 2001 From: Harshit Date: Fri, 12 Jun 2026 00:53:02 +0530 Subject: [PATCH 8/8] fix: Lint issues --- apps/backend/src/routes/auth.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index d56add32..11351267 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -1,4 +1,3 @@ -import { encrypt } from '../utils/encryption.js'; import { handleDbError, isGitHubTokenError, isGoogleTokenError } from '../utils/error.util.js'; import { extractRawJwt, blocklistKey, signAccessToken } from '../utils/jwt.js'; import { buildOAuthState, getMobileRedirectUri } from '../utils/oauth.js'; @@ -53,7 +52,7 @@ interface GitHubUserResponse { } -export async function authRoutes(app: FastifyInstance) { +export async function authRoutes(app: FastifyInstance): Promise { // Developer login bypass (development only) if (process.env.NODE_ENV !== 'production') { app.post('/dev-login', async (request: FastifyRequest, reply: FastifyReply) => { @@ -201,7 +200,7 @@ export async function authRoutes(app: FastifyInstance) { const existingAccount = await app.prisma.user.findUnique({ where: { - email: email + email } })