From 901d838a2c1035dbbb41e3694658c862e2a03ef7 Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 12 Jul 2026 17:51:31 +0330 Subject: [PATCH] bugfix: organization not selected problem fixed. being logged out too often fixed. --- backend/.env.example | 3 + backend/src/common/jwt-duration.ts | 39 +++++ backend/src/configs/configurations.ts | 21 +++ backend/src/modules/auth/auth.controller.ts | 46 +++++- backend/src/modules/auth/auth.service.ts | 72 ++++++++- frontend/src/lib/api/auth.ts | 9 +- frontend/src/lib/api/client.ts | 21 ++- frontend/src/lib/auth/accessToken.ts | 37 +++++ frontend/src/lib/auth/proactiveRefresh.ts | 155 ++++++++++++++++++++ frontend/src/lib/hooks/useAuth.tsx | 99 ++++++++++++- frontend/src/proxy.ts | 3 +- frontend/src/types/auth.ts | 1 + infrastructure/backend.prod.env.example | 3 + infrastructure/backend.staging.env.example | 3 + 14 files changed, 490 insertions(+), 22 deletions(-) create mode 100644 backend/src/common/jwt-duration.ts create mode 100644 frontend/src/lib/auth/accessToken.ts create mode 100644 frontend/src/lib/auth/proactiveRefresh.ts diff --git a/backend/.env.example b/backend/.env.example index a5e8458..3d2145e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -26,6 +26,9 @@ API_PREFIX=/api # CORS and invite links — must match the URL where the Next.js app runs FRONTEND_URL=http://localhost:3001 +# Set true when the app is served over HTTPS (required for Secure auth cookies) +COOKIE_SECURE=false + # OAuth (optional — uncomment when configured) # GOOGLE_CLIENT_ID=your-google-client-id # GOOGLE_CLIENT_SECRET=your-google-client-secret diff --git a/backend/src/common/jwt-duration.ts b/backend/src/common/jwt-duration.ts new file mode 100644 index 0000000..ac6c2c3 --- /dev/null +++ b/backend/src/common/jwt-duration.ts @@ -0,0 +1,39 @@ +const MS_PER_UNIT: Record = { + ms: 1, + s: 1000, + m: 60_000, + h: 3_600_000, + d: 86_400_000, + w: 7 * 86_400_000, + y: 365 * 86_400_000, +}; + +/** Parse jsonwebtoken-style durations (e.g. 15m, 30d, or seconds as plain number). */ +export function jwtDurationToMs(value: string): number { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error('JWT duration must not be empty'); + } + + if (/^\d+$/.test(trimmed)) { + return parseInt(trimmed, 10) * 1000; + } + + const match = trimmed.match(/^(\d+(?:\.\d+)?)(ms|s|m|h|d|w|y)?$/i); + if (!match) { + throw new Error(`Invalid JWT duration: "${value}"`); + } + + const amount = parseFloat(match[1]); + const unit = (match[2] ?? 's').toLowerCase(); + const multiplier = MS_PER_UNIT[unit]; + if (!multiplier) { + throw new Error(`Invalid JWT duration unit in "${value}"`); + } + + return amount * multiplier; +} + +export function sessionExpiresAtFromNow(refreshExpiresIn: string): Date { + return new Date(Date.now() + jwtDurationToMs(refreshExpiresIn)); +} diff --git a/backend/src/configs/configurations.ts b/backend/src/configs/configurations.ts index a1000f5..473fa83 100644 --- a/backend/src/configs/configurations.ts +++ b/backend/src/configs/configurations.ts @@ -31,6 +31,20 @@ function assertJwtTimespan(value: string, envKey: string): string { return trimmed; } +function parseEnvBoolean(value: string | undefined, defaultValue: boolean): boolean { + if (value === undefined || value.trim() === '') { + return defaultValue; + } + const normalized = value.trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) { + return true; + } + if (['0', 'false', 'no', 'off'].includes(normalized)) { + return false; + } + return defaultValue; +} + export interface Config { port: number; database: { @@ -42,6 +56,9 @@ export interface Config { refreshSecret: string; refreshExpiresIn: string; }; + cookie: { + secure: boolean; + }; throttle: { ttl: number; limit: number; @@ -88,6 +105,7 @@ export default (): Config => { getEnvVarWithDefault('JWT_REFRESH_EXPIRES_IN', '30d'), 'JWT_REFRESH_EXPIRES_IN', ); + const cookieSecure = parseEnvBoolean(process.env.COOKIE_SECURE, false); return { port: getEnvVarAsNumber('PORT', 3000), @@ -100,6 +118,9 @@ export default (): Config => { refreshSecret: jwtRefreshSecret, refreshExpiresIn: jwtRefreshExpiresIn, }, + cookie: { + secure: cookieSecure, + }, throttle: { ttl: getEnvVarAsNumber('THROTTLE_TTL', 60), limit: getEnvVarAsNumber('THROTTLE_LIMIT', 100), diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index dd97c37..94292b8 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -26,6 +26,8 @@ import { } from '@nestjs/swagger'; import { AuthService } from './auth.service'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; import { LoginDto } from './dto/login.dto'; import { RegisterDto } from './dto/register.dto'; import { CreateOrganizationDto } from './dto/create-organization.dto'; @@ -46,7 +48,11 @@ export class AuthController { private static readonly REMEMBER_ME_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; private static readonly PASSWORD_RESET_MAX_AGE_MS = 15 * 60 * 1000; - constructor(private readonly authService: AuthService) {} + constructor( + private readonly authService: AuthService, + private readonly configService: ConfigService, + private readonly jwtService: JwtService, + ) {} // ========================= // LOGIN @@ -165,10 +171,20 @@ export class AuthController { @ApiResponse({ status: 200, description: 'Profile retrieved successfully' }) @ApiUnauthorizedResponse({ description: 'Invalid or missing JWT token' }) async getProfile(@Req() req) { - console.log('Profile endpoint hit'); - console.log('USER FROM JWT:', req.user); + const result = await this.authService.getProfile(req.user.id); + const accessTokenExpiresAt = this.readAccessTokenExpiresAt(req); - return this.authService.getProfile(req.user.id); + if (!accessTokenExpiresAt || !result.data) { + return result; + } + + return { + ...result, + data: { + ...result.data, + accessTokenExpiresAt, + }, + }; } @Patch('profile/language') @@ -309,7 +325,10 @@ export class AuthController { throw new UnauthorizedException('Refresh token not found'); } - const result = await this.authService.refreshToken(refreshToken); + const result = await this.authService.refreshToken( + refreshToken, + req?.cookies?.accessToken, + ); this.setAccessToken( res, @@ -321,10 +340,25 @@ export class AuthController { success: true, data: { accessToken: result.data.accessToken, + accessTokenExpiresAt: result.data.accessTokenExpiresAt, }, }; } + private readAccessTokenExpiresAt(req: { cookies?: { accessToken?: string } }): string | undefined { + const token = req?.cookies?.accessToken; + if (!token) { + return undefined; + } + + const payload = this.jwtService.decode(token) as { exp?: number } | null; + if (!payload?.exp) { + return undefined; + } + + return new Date(payload.exp * 1000).toISOString(); + } + // ========================= // LOGOUT // ========================= @@ -366,7 +400,7 @@ export class AuthController { private baseCookieOptions() { return { httpOnly: true, - secure: false, // ⚠️ true in production (HTTPS) + secure: this.configService.get('cookie.secure') ?? false, sameSite: 'lax' as const, path: '/', }; diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 9b21706..3e88fce 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -32,6 +32,7 @@ import { ForgotPasswordVerifyDto, } from './dto/forgot-password.dto'; import { normalizeIranMobile } from '../../common/utils/mobile.util'; +import { sessionExpiresAtFromNow } from '../../common/jwt-duration'; import * as crypto from 'crypto'; const FORGOT_PASSWORD_PURPOSE = 'forgot_password'; @@ -97,6 +98,18 @@ export class AuthService { } as JwtSignOptions; } + private sessionExpiresAt(): Date { + const refreshExpiresIn = + this.configService.get('jwt.refreshExpiresIn') ?? '30d'; + return sessionExpiresAtFromNow(refreshExpiresIn); + } + + private accessTokenExpiresAt(): Date { + const accessExpiresIn = + this.configService.get('jwt.expiresIn') ?? '15m'; + return sessionExpiresAtFromNow(accessExpiresIn); + } + /** * Validate user credentials (used by LocalStrategy) * @param email - User's email @@ -182,7 +195,7 @@ export class AuthService { userId: user.id, token: accessToken, refreshToken: refreshToken, - expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days + expiresAt: this.sessionExpiresAt(), }, }); @@ -207,6 +220,7 @@ export class AuthService { data: { accessToken, refreshToken, + accessTokenExpiresAt: this.accessTokenExpiresAt().toISOString(), user: this.toPublicUser(user), organizations, }, @@ -459,9 +473,10 @@ export class AuthService { /** * Refresh access token using refresh token * @param refreshToken - Valid refresh token + * @param previousAccessToken - Expired access token used to preserve organization context * @returns New access token */ - async refreshToken(refreshToken: string) { + async refreshToken(refreshToken: string, previousAccessToken?: string) { try { // Verify the refresh token const payload = await this.jwtService.verifyAsync(refreshToken, { @@ -506,11 +521,17 @@ export class AuthService { throw new UnauthorizedException('Invalid refresh token'); } + const organizationId = await this.resolveOrganizationIdForRefresh( + previousAccessToken, + session.user.memberships, + ); + // Generate new access token const newAccessPayload: JwtPayload = { sub: session.user.id, email: session.user.email, type: 'access', + ...(organizationId ? { organizationId } : {}), }; const newAccessToken = await this.jwtService.signAsync( @@ -523,7 +544,7 @@ export class AuthService { where: { id: session.id }, data: { token: newAccessToken, - expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days + expiresAt: this.sessionExpiresAt(), }, }); @@ -547,6 +568,7 @@ export class AuthService { success: true, data: { accessToken: newAccessToken, + accessTokenExpiresAt: this.accessTokenExpiresAt().toISOString(), user: this.toPublicUser(session.user), organizations, }, @@ -1163,6 +1185,50 @@ export class AuthService { }; } + private async decodeAccessOrganizationId(token?: string): Promise { + if (!token?.trim()) { + return undefined; + } + + try { + const payload = (await this.jwtService.verifyAsync(token, { + secret: this.configService.get('jwt.secret')!, + ignoreExpiration: true, + })) as JwtPayload; + + return typeof payload.organizationId === 'string' ? payload.organizationId : undefined; + } catch { + return undefined; + } + } + + private async resolveOrganizationIdForRefresh( + previousAccessToken: string | undefined, + memberships: Array<{ + organizationId: string; + isOwner: boolean; + isActive: boolean; + }>, + ): Promise { + const activeMemberships = memberships.filter((m) => m.isOwner || m.isActive); + const candidateFromToken = await this.decodeAccessOrganizationId(previousAccessToken); + + if (candidateFromToken) { + const stillMember = activeMemberships.some( + (membership) => membership.organizationId === candidateFromToken, + ); + if (stillMember) { + return candidateFromToken; + } + } + + if (activeMemberships.length === 1) { + return activeMemberships[0].organizationId; + } + + return undefined; + } + private async getOwnerMembership(userId: string, organizationId: string) { if (!organizationId) { throw new BadRequestException('Organization is not selected'); diff --git a/frontend/src/lib/api/auth.ts b/frontend/src/lib/api/auth.ts index e861584..e56038b 100644 --- a/frontend/src/lib/api/auth.ts +++ b/frontend/src/lib/api/auth.ts @@ -60,9 +60,12 @@ export const authApi = { return response.data; }, - // Refresh token - refreshToken: async (refreshToken: string): Promise<{ accessToken: string }> => { - const response = await apiClient.post('/auth/refresh', { refreshToken }); + // Refresh access token using httpOnly cookies (same as the axios interceptor). + refreshSessionFromCookies: async (): Promise<{ + success: boolean; + data?: { accessToken?: string; accessTokenExpiresAt?: string }; + }> => { + const response = await apiClient.post('/auth/refresh', {}); return response.data; }, diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 6dd64e5..8ea3575 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -30,7 +30,6 @@ function isPublicInvitationRequest(url: string | undefined): boolean { function shouldSkipRefreshRetry(url: string | undefined): boolean { if (!url) return false; return ( - url.includes('/auth/profile') || url.includes('/auth/refresh') || url.includes('/auth/login') || url.includes('/auth/register') || @@ -57,13 +56,29 @@ apiClient.interceptors.response.use( originalRequest._retry = true; try { - // ✅ refresh via cookie (no body needed ideally) + // Refresh access token, then restore selected organization context on the new JWT. await axios.post( `${process.env.NEXT_PUBLIC_API_URL}/auth/refresh`, {}, - { withCredentials: true } + { withCredentials: true }, ); + const orgId = + typeof window !== 'undefined' + ? localStorage.getItem('currentOrganizationId') + : null; + if (orgId) { + try { + await axios.post( + `${process.env.NEXT_PUBLIC_API_URL}/auth/select-organization`, + { organizationId: orgId }, + { withCredentials: true }, + ); + } catch { + /* original retry may still succeed if refresh preserved organizationId */ + } + } + return apiClient(originalRequest); } catch (refreshError) { if (typeof window !== 'undefined') { diff --git a/frontend/src/lib/auth/accessToken.ts b/frontend/src/lib/auth/accessToken.ts new file mode 100644 index 0000000..3ea0f64 --- /dev/null +++ b/frontend/src/lib/auth/accessToken.ts @@ -0,0 +1,37 @@ +/** Decode JWT payload without verification — used only for coarse expiry checks in middleware. */ +function decodeJwtPayload(token: string): Record | null { + const parts = token.split('.'); + if (parts.length !== 3) { + return null; + } + + try { + const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '='); + const json = + typeof atob === 'function' + ? atob(padded) + : Buffer.from(padded, 'base64').toString('utf8'); + const payload = JSON.parse(json) as Record; + return payload && typeof payload === 'object' ? payload : null; + } catch { + return null; + } +} + +export function isAccessTokenExpired(token: string | undefined | null): boolean { + if (!token?.trim()) { + return true; + } + + const payload = decodeJwtPayload(token); + if (!payload || typeof payload.exp !== 'number') { + return true; + } + + return payload.exp * 1000 <= Date.now(); +} + +export function hasUsableAccessToken(token: string | undefined | null): boolean { + return Boolean(token?.trim()) && !isAccessTokenExpired(token); +} diff --git a/frontend/src/lib/auth/proactiveRefresh.ts b/frontend/src/lib/auth/proactiveRefresh.ts new file mode 100644 index 0000000..8178958 --- /dev/null +++ b/frontend/src/lib/auth/proactiveRefresh.ts @@ -0,0 +1,155 @@ +import { authApi } from '@/lib/api/auth'; + +const STORAGE_KEY = 'dyolink.accessTokenExpiresAt'; +/** Refresh this long before the access JWT expires. */ +const REFRESH_BUFFER_MS = 2 * 60 * 1000; +/** Safety cap — never call /auth/refresh more than once per minute per tab. */ +const MIN_REFRESH_GAP_MS = 60 * 1000; + +let timerId: ReturnType | null = null; +let refreshInFlight: Promise | null = null; +let lastRefreshAt = 0; + +export function setAccessTokenExpiresAt(iso: string): void { + if (typeof window === 'undefined') { + return; + } + sessionStorage.setItem(STORAGE_KEY, iso); +} + +export function clearAccessTokenExpiresAt(): void { + if (typeof window === 'undefined') { + return; + } + sessionStorage.removeItem(STORAGE_KEY); +} + +export function rememberAccessTokenExpiresAt( + iso: string | undefined | null, +): void { + if (iso) { + setAccessTokenExpiresAt(iso); + } +} + +function getAccessTokenExpiresAtMs(): number | null { + if (typeof window === 'undefined') { + return null; + } + + const raw = sessionStorage.getItem(STORAGE_KEY); + if (!raw) { + return null; + } + + const ms = Date.parse(raw); + return Number.isFinite(ms) ? ms : null; +} + +function clearTimer(): void { + if (timerId !== null) { + clearTimeout(timerId); + timerId = null; + } +} + +function computeDelayMs(expiresAtMs: number): number { + const refreshAt = expiresAtMs - REFRESH_BUFFER_MS; + const delay = refreshAt - Date.now(); + return Math.max(delay, MIN_REFRESH_GAP_MS); +} + +function shouldRefreshNow(expiresAtMs: number): boolean { + return expiresAtMs - REFRESH_BUFFER_MS <= Date.now(); +} + +async function refreshAccessTokenWithOrg(): Promise { + if (refreshInFlight) { + return refreshInFlight; + } + + if (Date.now() - lastRefreshAt < MIN_REFRESH_GAP_MS) { + const expiresAtMs = getAccessTokenExpiresAtMs(); + return expiresAtMs + ? new Date(expiresAtMs).toISOString() + : undefined; + } + + refreshInFlight = (async () => { + try { + const result = await authApi.refreshSessionFromCookies(); + const expiresAt = result.data?.accessTokenExpiresAt; + rememberAccessTokenExpiresAt(expiresAt); + + const orgId = localStorage.getItem('currentOrganizationId'); + if (orgId) { + try { + await authApi.selectOrganization(orgId); + } catch { + /* refresh may already preserve organizationId on the JWT */ + } + } + + lastRefreshAt = Date.now(); + return expiresAt; + } finally { + refreshInFlight = null; + } + })(); + + return refreshInFlight; +} + +function scheduleNextRefresh(reschedule: () => void): void { + clearTimer(); + + if (typeof document !== 'undefined' && document.visibilityState !== 'visible') { + return; + } + + const expiresAtMs = getAccessTokenExpiresAtMs(); + if (!expiresAtMs) { + return; + } + + timerId = setTimeout(() => { + void refreshAccessTokenWithOrg() + .catch(() => { + /* reactive refresh / next visibility check will recover */ + }) + .finally(reschedule); + }, computeDelayMs(expiresAtMs)); +} + +/** Keeps the access cookie fresh while the tab is visible. Returns a cleanup function. */ +export function startProactiveSessionRefresh(): () => void { + if (typeof window === 'undefined') { + return () => undefined; + } + + const reschedule = () => scheduleNextRefresh(reschedule); + + const onVisibilityChange = () => { + if (document.visibilityState === 'visible') { + const expiresAtMs = getAccessTokenExpiresAtMs(); + if (expiresAtMs && shouldRefreshNow(expiresAtMs)) { + void refreshAccessTokenWithOrg() + .catch(() => undefined) + .finally(reschedule); + } else { + reschedule(); + } + return; + } + + clearTimer(); + }; + + document.addEventListener('visibilitychange', onVisibilityChange); + reschedule(); + + return () => { + document.removeEventListener('visibilitychange', onVisibilityChange); + clearTimer(); + }; +} diff --git a/frontend/src/lib/hooks/useAuth.tsx b/frontend/src/lib/hooks/useAuth.tsx index f037fc5..678d87d 100644 --- a/frontend/src/lib/hooks/useAuth.tsx +++ b/frontend/src/lib/hooks/useAuth.tsx @@ -11,6 +11,11 @@ import { } from '@/lib/auth/rememberMe'; import { User, Organization } from '@/types/organization'; import { isAppLocale, getLocaleFromPathname } from '@/i18n/routing'; +import { + clearAccessTokenExpiresAt, + rememberAccessTokenExpiresAt, + startProactiveSessionRefresh, +} from '@/lib/auth/proactiveRefresh'; interface AuthContextType { user: User | null; @@ -91,6 +96,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (response.success) { const { user: userData, organizations: orgs } = normalizeProfilePayload(response.data); + rememberAccessTokenExpiresAt( + (response.data as { accessTokenExpiresAt?: string }).accessTokenExpiresAt, + ); setUser(userData); setOrganizations(orgs); @@ -99,17 +107,29 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (storedOrgId && orgs.length > 0) { const org = orgs.find(o => o.id === storedOrgId); if (org) { - setCurrentOrganization(org); - // Ensure cookie token carries organizationId for org-scoped APIs. - await authApi.selectOrganization(org.id); + const selected = await authApi.selectOrganization(org.id); + setCurrentOrganization({ + id: selected.data.organization.id, + name: selected.data.organization.name, + type: selected.data.organization.type as Organization['type'], + isOwner: Boolean(selected.data.organization.isOwner), + permissions: selected.data.organization.permissions, + plan: selected.data.organization.plan, + }); } else { setCurrentOrganization(null); } } else if (orgs.length === 1 && userData) { - setCurrentOrganization(orgs[0]); + const selected = await authApi.selectOrganization(orgs[0].id); localStorage.setItem('currentOrganizationId', orgs[0].id); - // Keep JWT in sync with selected org even for single-org users. - await authApi.selectOrganization(orgs[0].id); + setCurrentOrganization({ + id: selected.data.organization.id, + name: selected.data.organization.name, + type: selected.data.organization.type as Organization['type'], + isOwner: Boolean(selected.data.organization.isOwner), + permissions: selected.data.organization.permissions, + plan: selected.data.organization.plan, + }); } else { setCurrentOrganization(null); } @@ -119,6 +139,61 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { (err as { statusCode?: number })?.statusCode ?? (err as { response?: { status?: number } })?.response?.status; + // Access token may have expired while refresh cookie is still valid (e.g. JWT_EXPIRES_IN=15m). + if (status === 401) { + try { + const refreshResult = await authApi.refreshSessionFromCookies(); + rememberAccessTokenExpiresAt(refreshResult.data?.accessTokenExpiresAt); + const orgId = localStorage.getItem('currentOrganizationId'); + if (orgId) { + await authApi.selectOrganization(orgId); + } + const retry = await authApi.getProfile(); + if (retry.success) { + const { user: userData, organizations: orgs } = normalizeProfilePayload(retry.data); + rememberAccessTokenExpiresAt( + (retry.data as { accessTokenExpiresAt?: string }).accessTokenExpiresAt, + ); + setUser(userData); + setOrganizations(orgs); + + const storedOrgId = localStorage.getItem('currentOrganizationId'); + if (storedOrgId && orgs.length > 0) { + const org = orgs.find((o) => o.id === storedOrgId); + if (org) { + const selected = await authApi.selectOrganization(org.id); + setCurrentOrganization({ + id: selected.data.organization.id, + name: selected.data.organization.name, + type: selected.data.organization.type as Organization['type'], + isOwner: Boolean(selected.data.organization.isOwner), + permissions: selected.data.organization.permissions, + plan: selected.data.organization.plan, + }); + } else { + setCurrentOrganization(null); + } + } else if (orgs.length === 1 && userData) { + const selected = await authApi.selectOrganization(orgs[0].id); + localStorage.setItem('currentOrganizationId', orgs[0].id); + setCurrentOrganization({ + id: selected.data.organization.id, + name: selected.data.organization.name, + type: selected.data.organization.type as Organization['type'], + isOwner: Boolean(selected.data.organization.isOwner), + permissions: selected.data.organization.permissions, + plan: selected.data.organization.plan, + }); + } else { + setCurrentOrganization(null); + } + return; + } + } catch { + /* fall through to logged-out state */ + } + } + // 401 on profile is expected when there is no session — not an application error. if (status !== 401) { console.error('Auth check failed:', err); @@ -128,6 +203,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setUser(null); setOrganizations([]); setCurrentOrganization(null); + clearAccessTokenExpiresAt(); } finally { setIsLoading(false); setIsAuthReady(true); @@ -138,6 +214,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { void checkAuth(); }, [checkAuth]); + useEffect(() => { + if (!user || !isAuthReady) { + return; + } + + return startProactiveSessionRefresh(); + }, [user, isAuthReady]); + const applyUrlLocaleToUser = useCallback(async (user: User): Promise => { if (typeof window === 'undefined') return user; @@ -180,6 +264,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }); const userData = await applyUrlLocaleToUser(response.data.user); + rememberAccessTokenExpiresAt(response.data.accessTokenExpiresAt); setUser(userData); setOrganizations(response.data.organizations); @@ -223,6 +308,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } const userData = await applyUrlLocaleToUser(response.data.user); + rememberAccessTokenExpiresAt(response.data.accessTokenExpiresAt); setUser(userData); setOrganizations(response.data.organizations); @@ -263,6 +349,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setOrganizations([]); setCurrentOrganization(null); setError(null); + clearAccessTokenExpiresAt(); setIsAuthReady(true); router.replace('/'); router.refresh(); diff --git a/frontend/src/proxy.ts b/frontend/src/proxy.ts index 18fc536..26d1888 100644 --- a/frontend/src/proxy.ts +++ b/frontend/src/proxy.ts @@ -6,6 +6,7 @@ import { routing, stripLocaleFromPathname, } from './i18n/routing'; +import { hasUsableAccessToken } from './lib/auth/accessToken'; const handleI18nRouting = createMiddleware(routing); @@ -36,7 +37,7 @@ export function proxy(request: NextRequest) { const pathWithoutLocale = stripLocaleFromPathname(pathname); const locale = getLocaleFromPathname(pathname); const token = request.cookies.get('accessToken')?.value; - const isAuthenticated = !!token; + const isAuthenticated = hasUsableAccessToken(token); if (isAuthenticated && pathWithoutLocale === '/') { return NextResponse.redirect(new URL(`/${locale}/today`, request.url)); diff --git a/frontend/src/types/auth.ts b/frontend/src/types/auth.ts index 7ef75b3..60a75f2 100644 --- a/frontend/src/types/auth.ts +++ b/frontend/src/types/auth.ts @@ -5,6 +5,7 @@ export interface AuthResponse { data: { accessToken: string; refreshToken: string; + accessTokenExpiresAt?: string; user: User; organizations: Organization[]; }; diff --git a/infrastructure/backend.prod.env.example b/infrastructure/backend.prod.env.example index 8a3c474..323cc86 100644 --- a/infrastructure/backend.prod.env.example +++ b/infrastructure/backend.prod.env.example @@ -14,6 +14,9 @@ JWT_REFRESH_EXPIRES_IN=30d # Must match DOMAIN in .env — used for CORS, invite links, cookies FRONTEND_URL=https://wixur.ir +# Required for HTTPS — browsers reject Secure cookies over plain HTTP +COOKIE_SECURE=true + # SMS (sms.ir) SMS_IR_API_KEY=CHANGE_ME_SMS_IR_API_KEY SMS_IR_TEMPLATE_ID=123456 diff --git a/infrastructure/backend.staging.env.example b/infrastructure/backend.staging.env.example index 2f70917..6ae019f 100644 --- a/infrastructure/backend.staging.env.example +++ b/infrastructure/backend.staging.env.example @@ -12,6 +12,9 @@ JWT_REFRESH_EXPIRES_IN=30d # CORS, cookies, and invite links — must match how users open the app (nginx host port) FRONTEND_URL=http://178.131.50.201:8088 +# HTTP staging — keep false unless you terminate TLS in front of the app +COOKIE_SECURE=false + # SMS (sms.ir) SMS_IR_API_KEY=CHANGE_ME_SMS_IR_API_KEY SMS_IR_TEMPLATE_ID=123456