bugfix: organization not selected problem fixed. being logged out too often fixed.
This commit is contained in:
@@ -26,6 +26,9 @@ API_PREFIX=/api
|
|||||||
# CORS and invite links — must match the URL where the Next.js app runs
|
# CORS and invite links — must match the URL where the Next.js app runs
|
||||||
FRONTEND_URL=http://localhost:3001
|
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)
|
# OAuth (optional — uncomment when configured)
|
||||||
# GOOGLE_CLIENT_ID=your-google-client-id
|
# GOOGLE_CLIENT_ID=your-google-client-id
|
||||||
# GOOGLE_CLIENT_SECRET=your-google-client-secret
|
# GOOGLE_CLIENT_SECRET=your-google-client-secret
|
||||||
|
|||||||
39
backend/src/common/jwt-duration.ts
Normal file
39
backend/src/common/jwt-duration.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
const MS_PER_UNIT: Record<string, number> = {
|
||||||
|
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));
|
||||||
|
}
|
||||||
@@ -31,6 +31,20 @@ function assertJwtTimespan(value: string, envKey: string): string {
|
|||||||
return trimmed;
|
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 {
|
export interface Config {
|
||||||
port: number;
|
port: number;
|
||||||
database: {
|
database: {
|
||||||
@@ -42,6 +56,9 @@ export interface Config {
|
|||||||
refreshSecret: string;
|
refreshSecret: string;
|
||||||
refreshExpiresIn: string;
|
refreshExpiresIn: string;
|
||||||
};
|
};
|
||||||
|
cookie: {
|
||||||
|
secure: boolean;
|
||||||
|
};
|
||||||
throttle: {
|
throttle: {
|
||||||
ttl: number;
|
ttl: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
@@ -88,6 +105,7 @@ export default (): Config => {
|
|||||||
getEnvVarWithDefault('JWT_REFRESH_EXPIRES_IN', '30d'),
|
getEnvVarWithDefault('JWT_REFRESH_EXPIRES_IN', '30d'),
|
||||||
'JWT_REFRESH_EXPIRES_IN',
|
'JWT_REFRESH_EXPIRES_IN',
|
||||||
);
|
);
|
||||||
|
const cookieSecure = parseEnvBoolean(process.env.COOKIE_SECURE, false);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
port: getEnvVarAsNumber('PORT', 3000),
|
port: getEnvVarAsNumber('PORT', 3000),
|
||||||
@@ -100,6 +118,9 @@ export default (): Config => {
|
|||||||
refreshSecret: jwtRefreshSecret,
|
refreshSecret: jwtRefreshSecret,
|
||||||
refreshExpiresIn: jwtRefreshExpiresIn,
|
refreshExpiresIn: jwtRefreshExpiresIn,
|
||||||
},
|
},
|
||||||
|
cookie: {
|
||||||
|
secure: cookieSecure,
|
||||||
|
},
|
||||||
throttle: {
|
throttle: {
|
||||||
ttl: getEnvVarAsNumber('THROTTLE_TTL', 60),
|
ttl: getEnvVarAsNumber('THROTTLE_TTL', 60),
|
||||||
limit: getEnvVarAsNumber('THROTTLE_LIMIT', 100),
|
limit: getEnvVarAsNumber('THROTTLE_LIMIT', 100),
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import {
|
|||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
|
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
import { CreateOrganizationDto } from './dto/create-organization.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 REMEMBER_ME_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
private static readonly PASSWORD_RESET_MAX_AGE_MS = 15 * 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
|
// LOGIN
|
||||||
@@ -165,10 +171,20 @@ export class AuthController {
|
|||||||
@ApiResponse({ status: 200, description: 'Profile retrieved successfully' })
|
@ApiResponse({ status: 200, description: 'Profile retrieved successfully' })
|
||||||
@ApiUnauthorizedResponse({ description: 'Invalid or missing JWT token' })
|
@ApiUnauthorizedResponse({ description: 'Invalid or missing JWT token' })
|
||||||
async getProfile(@Req() req) {
|
async getProfile(@Req() req) {
|
||||||
console.log('Profile endpoint hit');
|
const result = await this.authService.getProfile(req.user.id);
|
||||||
console.log('USER FROM JWT:', req.user);
|
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')
|
@Patch('profile/language')
|
||||||
@@ -309,7 +325,10 @@ export class AuthController {
|
|||||||
throw new UnauthorizedException('Refresh token not found');
|
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(
|
this.setAccessToken(
|
||||||
res,
|
res,
|
||||||
@@ -321,10 +340,25 @@ export class AuthController {
|
|||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
accessToken: result.data.accessToken,
|
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
|
// LOGOUT
|
||||||
// =========================
|
// =========================
|
||||||
@@ -366,7 +400,7 @@ export class AuthController {
|
|||||||
private baseCookieOptions() {
|
private baseCookieOptions() {
|
||||||
return {
|
return {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: false, // ⚠️ true in production (HTTPS)
|
secure: this.configService.get<boolean>('cookie.secure') ?? false,
|
||||||
sameSite: 'lax' as const,
|
sameSite: 'lax' as const,
|
||||||
path: '/',
|
path: '/',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
ForgotPasswordVerifyDto,
|
ForgotPasswordVerifyDto,
|
||||||
} from './dto/forgot-password.dto';
|
} from './dto/forgot-password.dto';
|
||||||
import { normalizeIranMobile } from '../../common/utils/mobile.util';
|
import { normalizeIranMobile } from '../../common/utils/mobile.util';
|
||||||
|
import { sessionExpiresAtFromNow } from '../../common/jwt-duration';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
const FORGOT_PASSWORD_PURPOSE = 'forgot_password';
|
const FORGOT_PASSWORD_PURPOSE = 'forgot_password';
|
||||||
@@ -97,6 +98,18 @@ export class AuthService {
|
|||||||
} as JwtSignOptions;
|
} as JwtSignOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sessionExpiresAt(): Date {
|
||||||
|
const refreshExpiresIn =
|
||||||
|
this.configService.get<string>('jwt.refreshExpiresIn') ?? '30d';
|
||||||
|
return sessionExpiresAtFromNow(refreshExpiresIn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private accessTokenExpiresAt(): Date {
|
||||||
|
const accessExpiresIn =
|
||||||
|
this.configService.get<string>('jwt.expiresIn') ?? '15m';
|
||||||
|
return sessionExpiresAtFromNow(accessExpiresIn);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate user credentials (used by LocalStrategy)
|
* Validate user credentials (used by LocalStrategy)
|
||||||
* @param email - User's email
|
* @param email - User's email
|
||||||
@@ -182,7 +195,7 @@ export class AuthService {
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
token: accessToken,
|
token: accessToken,
|
||||||
refreshToken: refreshToken,
|
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: {
|
data: {
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
|
accessTokenExpiresAt: this.accessTokenExpiresAt().toISOString(),
|
||||||
user: this.toPublicUser(user),
|
user: this.toPublicUser(user),
|
||||||
organizations,
|
organizations,
|
||||||
},
|
},
|
||||||
@@ -459,9 +473,10 @@ export class AuthService {
|
|||||||
/**
|
/**
|
||||||
* Refresh access token using refresh token
|
* Refresh access token using refresh token
|
||||||
* @param refreshToken - Valid refresh token
|
* @param refreshToken - Valid refresh token
|
||||||
|
* @param previousAccessToken - Expired access token used to preserve organization context
|
||||||
* @returns New access token
|
* @returns New access token
|
||||||
*/
|
*/
|
||||||
async refreshToken(refreshToken: string) {
|
async refreshToken(refreshToken: string, previousAccessToken?: string) {
|
||||||
try {
|
try {
|
||||||
// Verify the refresh token
|
// Verify the refresh token
|
||||||
const payload = await this.jwtService.verifyAsync(refreshToken, {
|
const payload = await this.jwtService.verifyAsync(refreshToken, {
|
||||||
@@ -506,11 +521,17 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException('Invalid refresh token');
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const organizationId = await this.resolveOrganizationIdForRefresh(
|
||||||
|
previousAccessToken,
|
||||||
|
session.user.memberships,
|
||||||
|
);
|
||||||
|
|
||||||
// Generate new access token
|
// Generate new access token
|
||||||
const newAccessPayload: JwtPayload = {
|
const newAccessPayload: JwtPayload = {
|
||||||
sub: session.user.id,
|
sub: session.user.id,
|
||||||
email: session.user.email,
|
email: session.user.email,
|
||||||
type: 'access',
|
type: 'access',
|
||||||
|
...(organizationId ? { organizationId } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const newAccessToken = await this.jwtService.signAsync(
|
const newAccessToken = await this.jwtService.signAsync(
|
||||||
@@ -523,7 +544,7 @@ export class AuthService {
|
|||||||
where: { id: session.id },
|
where: { id: session.id },
|
||||||
data: {
|
data: {
|
||||||
token: newAccessToken,
|
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,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
accessToken: newAccessToken,
|
accessToken: newAccessToken,
|
||||||
|
accessTokenExpiresAt: this.accessTokenExpiresAt().toISOString(),
|
||||||
user: this.toPublicUser(session.user),
|
user: this.toPublicUser(session.user),
|
||||||
organizations,
|
organizations,
|
||||||
},
|
},
|
||||||
@@ -1163,6 +1185,50 @@ export class AuthService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async decodeAccessOrganizationId(token?: string): Promise<string | undefined> {
|
||||||
|
if (!token?.trim()) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = (await this.jwtService.verifyAsync(token, {
|
||||||
|
secret: this.configService.get<string>('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<string | undefined> {
|
||||||
|
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) {
|
private async getOwnerMembership(userId: string, organizationId: string) {
|
||||||
if (!organizationId) {
|
if (!organizationId) {
|
||||||
throw new BadRequestException('Organization is not selected');
|
throw new BadRequestException('Organization is not selected');
|
||||||
|
|||||||
@@ -60,9 +60,12 @@ export const authApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Refresh token
|
// Refresh access token using httpOnly cookies (same as the axios interceptor).
|
||||||
refreshToken: async (refreshToken: string): Promise<{ accessToken: string }> => {
|
refreshSessionFromCookies: async (): Promise<{
|
||||||
const response = await apiClient.post('/auth/refresh', { refreshToken });
|
success: boolean;
|
||||||
|
data?: { accessToken?: string; accessTokenExpiresAt?: string };
|
||||||
|
}> => {
|
||||||
|
const response = await apiClient.post('/auth/refresh', {});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ function isPublicInvitationRequest(url: string | undefined): boolean {
|
|||||||
function shouldSkipRefreshRetry(url: string | undefined): boolean {
|
function shouldSkipRefreshRetry(url: string | undefined): boolean {
|
||||||
if (!url) return false;
|
if (!url) return false;
|
||||||
return (
|
return (
|
||||||
url.includes('/auth/profile') ||
|
|
||||||
url.includes('/auth/refresh') ||
|
url.includes('/auth/refresh') ||
|
||||||
url.includes('/auth/login') ||
|
url.includes('/auth/login') ||
|
||||||
url.includes('/auth/register') ||
|
url.includes('/auth/register') ||
|
||||||
@@ -57,13 +56,29 @@ apiClient.interceptors.response.use(
|
|||||||
originalRequest._retry = true;
|
originalRequest._retry = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// ✅ refresh via cookie (no body needed ideally)
|
// Refresh access token, then restore selected organization context on the new JWT.
|
||||||
await axios.post(
|
await axios.post(
|
||||||
`${process.env.NEXT_PUBLIC_API_URL}/auth/refresh`,
|
`${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);
|
return apiClient(originalRequest);
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
|
|||||||
37
frontend/src/lib/auth/accessToken.ts
Normal file
37
frontend/src/lib/auth/accessToken.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/** Decode JWT payload without verification — used only for coarse expiry checks in middleware. */
|
||||||
|
function decodeJwtPayload(token: string): Record<string, unknown> | 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<string, unknown>;
|
||||||
|
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);
|
||||||
|
}
|
||||||
155
frontend/src/lib/auth/proactiveRefresh.ts
Normal file
155
frontend/src/lib/auth/proactiveRefresh.ts
Normal file
@@ -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<typeof setTimeout> | null = null;
|
||||||
|
let refreshInFlight: Promise<string | undefined> | 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<string | undefined> {
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,6 +11,11 @@ import {
|
|||||||
} from '@/lib/auth/rememberMe';
|
} from '@/lib/auth/rememberMe';
|
||||||
import { User, Organization } from '@/types/organization';
|
import { User, Organization } from '@/types/organization';
|
||||||
import { isAppLocale, getLocaleFromPathname } from '@/i18n/routing';
|
import { isAppLocale, getLocaleFromPathname } from '@/i18n/routing';
|
||||||
|
import {
|
||||||
|
clearAccessTokenExpiresAt,
|
||||||
|
rememberAccessTokenExpiresAt,
|
||||||
|
startProactiveSessionRefresh,
|
||||||
|
} from '@/lib/auth/proactiveRefresh';
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
@@ -91,6 +96,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
const { user: userData, organizations: orgs } = normalizeProfilePayload(response.data);
|
const { user: userData, organizations: orgs } = normalizeProfilePayload(response.data);
|
||||||
|
rememberAccessTokenExpiresAt(
|
||||||
|
(response.data as { accessTokenExpiresAt?: string }).accessTokenExpiresAt,
|
||||||
|
);
|
||||||
|
|
||||||
setUser(userData);
|
setUser(userData);
|
||||||
setOrganizations(orgs);
|
setOrganizations(orgs);
|
||||||
@@ -99,17 +107,29 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
if (storedOrgId && orgs.length > 0) {
|
if (storedOrgId && orgs.length > 0) {
|
||||||
const org = orgs.find(o => o.id === storedOrgId);
|
const org = orgs.find(o => o.id === storedOrgId);
|
||||||
if (org) {
|
if (org) {
|
||||||
setCurrentOrganization(org);
|
const selected = await authApi.selectOrganization(org.id);
|
||||||
// Ensure cookie token carries organizationId for org-scoped APIs.
|
setCurrentOrganization({
|
||||||
await authApi.selectOrganization(org.id);
|
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 {
|
} else {
|
||||||
setCurrentOrganization(null);
|
setCurrentOrganization(null);
|
||||||
}
|
}
|
||||||
} else if (orgs.length === 1 && userData) {
|
} else if (orgs.length === 1 && userData) {
|
||||||
setCurrentOrganization(orgs[0]);
|
const selected = await authApi.selectOrganization(orgs[0].id);
|
||||||
localStorage.setItem('currentOrganizationId', orgs[0].id);
|
localStorage.setItem('currentOrganizationId', orgs[0].id);
|
||||||
// Keep JWT in sync with selected org even for single-org users.
|
setCurrentOrganization({
|
||||||
await authApi.selectOrganization(orgs[0].id);
|
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 {
|
} else {
|
||||||
setCurrentOrganization(null);
|
setCurrentOrganization(null);
|
||||||
}
|
}
|
||||||
@@ -119,6 +139,61 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
(err as { statusCode?: number })?.statusCode ??
|
(err as { statusCode?: number })?.statusCode ??
|
||||||
(err as { response?: { status?: number } })?.response?.status;
|
(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.
|
// 401 on profile is expected when there is no session — not an application error.
|
||||||
if (status !== 401) {
|
if (status !== 401) {
|
||||||
console.error('Auth check failed:', err);
|
console.error('Auth check failed:', err);
|
||||||
@@ -128,6 +203,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setUser(null);
|
setUser(null);
|
||||||
setOrganizations([]);
|
setOrganizations([]);
|
||||||
setCurrentOrganization(null);
|
setCurrentOrganization(null);
|
||||||
|
clearAccessTokenExpiresAt();
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setIsAuthReady(true);
|
setIsAuthReady(true);
|
||||||
@@ -138,6 +214,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
void checkAuth();
|
void checkAuth();
|
||||||
}, [checkAuth]);
|
}, [checkAuth]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user || !isAuthReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return startProactiveSessionRefresh();
|
||||||
|
}, [user, isAuthReady]);
|
||||||
|
|
||||||
const applyUrlLocaleToUser = useCallback(async (user: User): Promise<User> => {
|
const applyUrlLocaleToUser = useCallback(async (user: User): Promise<User> => {
|
||||||
if (typeof window === 'undefined') return user;
|
if (typeof window === 'undefined') return user;
|
||||||
|
|
||||||
@@ -180,6 +264,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const userData = await applyUrlLocaleToUser(response.data.user);
|
const userData = await applyUrlLocaleToUser(response.data.user);
|
||||||
|
rememberAccessTokenExpiresAt(response.data.accessTokenExpiresAt);
|
||||||
setUser(userData);
|
setUser(userData);
|
||||||
setOrganizations(response.data.organizations);
|
setOrganizations(response.data.organizations);
|
||||||
|
|
||||||
@@ -223,6 +308,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const userData = await applyUrlLocaleToUser(response.data.user);
|
const userData = await applyUrlLocaleToUser(response.data.user);
|
||||||
|
rememberAccessTokenExpiresAt(response.data.accessTokenExpiresAt);
|
||||||
setUser(userData);
|
setUser(userData);
|
||||||
setOrganizations(response.data.organizations);
|
setOrganizations(response.data.organizations);
|
||||||
|
|
||||||
@@ -263,6 +349,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setOrganizations([]);
|
setOrganizations([]);
|
||||||
setCurrentOrganization(null);
|
setCurrentOrganization(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
clearAccessTokenExpiresAt();
|
||||||
setIsAuthReady(true);
|
setIsAuthReady(true);
|
||||||
router.replace('/');
|
router.replace('/');
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
routing,
|
routing,
|
||||||
stripLocaleFromPathname,
|
stripLocaleFromPathname,
|
||||||
} from './i18n/routing';
|
} from './i18n/routing';
|
||||||
|
import { hasUsableAccessToken } from './lib/auth/accessToken';
|
||||||
|
|
||||||
const handleI18nRouting = createMiddleware(routing);
|
const handleI18nRouting = createMiddleware(routing);
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ export function proxy(request: NextRequest) {
|
|||||||
const pathWithoutLocale = stripLocaleFromPathname(pathname);
|
const pathWithoutLocale = stripLocaleFromPathname(pathname);
|
||||||
const locale = getLocaleFromPathname(pathname);
|
const locale = getLocaleFromPathname(pathname);
|
||||||
const token = request.cookies.get('accessToken')?.value;
|
const token = request.cookies.get('accessToken')?.value;
|
||||||
const isAuthenticated = !!token;
|
const isAuthenticated = hasUsableAccessToken(token);
|
||||||
|
|
||||||
if (isAuthenticated && pathWithoutLocale === '/') {
|
if (isAuthenticated && pathWithoutLocale === '/') {
|
||||||
return NextResponse.redirect(new URL(`/${locale}/today`, request.url));
|
return NextResponse.redirect(new URL(`/${locale}/today`, request.url));
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface AuthResponse {
|
|||||||
data: {
|
data: {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
|
accessTokenExpiresAt?: string;
|
||||||
user: User;
|
user: User;
|
||||||
organizations: Organization[];
|
organizations: Organization[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ JWT_REFRESH_EXPIRES_IN=30d
|
|||||||
# Must match DOMAIN in .env — used for CORS, invite links, cookies
|
# Must match DOMAIN in .env — used for CORS, invite links, cookies
|
||||||
FRONTEND_URL=https://wixur.ir
|
FRONTEND_URL=https://wixur.ir
|
||||||
|
|
||||||
|
# Required for HTTPS — browsers reject Secure cookies over plain HTTP
|
||||||
|
COOKIE_SECURE=true
|
||||||
|
|
||||||
# SMS (sms.ir)
|
# SMS (sms.ir)
|
||||||
SMS_IR_API_KEY=CHANGE_ME_SMS_IR_API_KEY
|
SMS_IR_API_KEY=CHANGE_ME_SMS_IR_API_KEY
|
||||||
SMS_IR_TEMPLATE_ID=123456
|
SMS_IR_TEMPLATE_ID=123456
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ JWT_REFRESH_EXPIRES_IN=30d
|
|||||||
# CORS, cookies, and invite links — must match how users open the app (nginx host port)
|
# CORS, cookies, and invite links — must match how users open the app (nginx host port)
|
||||||
FRONTEND_URL=http://178.131.50.201:8088
|
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 (sms.ir)
|
||||||
SMS_IR_API_KEY=CHANGE_ME_SMS_IR_API_KEY
|
SMS_IR_API_KEY=CHANGE_ME_SMS_IR_API_KEY
|
||||||
SMS_IR_TEMPLATE_ID=123456
|
SMS_IR_TEMPLATE_ID=123456
|
||||||
|
|||||||
Reference in New Issue
Block a user