bugfix: organization not selected problem fixed. being logged out too often fixed.

This commit is contained in:
2026-07-12 17:51:31 +03:30
parent 39935688ad
commit 901d838a2c
14 changed files with 490 additions and 22 deletions

View File

@@ -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

View 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));
}

View File

@@ -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),

View File

@@ -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<boolean>('cookie.secure') ?? false,
sameSite: 'lax' as const,
path: '/',
};

View File

@@ -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<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)
* @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<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) {
if (!organizationId) {
throw new BadRequestException('Organization is not selected');