Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ MOODLE_MASTER_KEY=
JWT_SECRET=
REFRESH_SECRET=

# Optional JWT tuning (defaults shown)
# JWT_ACCESS_TOKEN_EXPIRY=300s
# JWT_REFRESH_TOKEN_EXPIRY=30d
# JWT_BCRYPT_ROUNDS=10

# Redis (caching and job queues)
REDIS_URL=redis://localhost:6379

Expand Down
25 changes: 22 additions & 3 deletions .github/workflows/pr-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ jobs:

- name: Discord notification
if: ${{ github.event_name == 'pull_request' }}
continue-on-error: true
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_PR_WEBHOOK }}
DISCORD_USERNAME: "Leo Bermudez"
Expand All @@ -77,6 +78,24 @@ jobs:
"timestamp": "${{ github.event.pull_request.updated_at }}"
}
]
uses: Ilshidur/action-discord@0.4.0
with:
args: ${{ env.LINT_STATUS == '✅ Lint passed, clean code! ✨' && '✅ Lint passed! pwede na mag NU' || 'Naa kay sayup, wala kay respeto! 😡' }}
DISCORD_MESSAGE: ${{ env.LINT_STATUS == '✅ Lint passed, clean code! ✨' && '✅ Lint passed! pwede na mag NU' || 'Naa kay sayup, wala kay respeto! 😡' }}
run: |
payload=$(jq -nc \
--arg username "$DISCORD_USERNAME" \
--arg avatar_url "$DISCORD_AVATAR" \
--arg content "$DISCORD_MESSAGE" \
--argjson embeds "$DISCORD_EMBEDS" \
'{username: $username, avatar_url: $avatar_url, content: $content, embeds: $embeds}')

if [ -z "$DISCORD_WEBHOOK" ]; then
echo "DISCORD_PR_WEBHOOK is not configured; skipping Discord notification."
exit 0
fi

curl \
--fail \
--show-error \
--silent \
-H "Content-Type: application/json" \
-d "$payload" \
"$DISCORD_WEBHOOK"
25 changes: 22 additions & 3 deletions .github/workflows/pr-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ jobs:

- name: Discord notification
if: ${{ github.event_name == 'pull_request' }}
continue-on-error: true
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_PR_WEBHOOK }}
DISCORD_USERNAME: "Jennifer Garrido-Amores"
Expand Down Expand Up @@ -169,6 +170,24 @@ jobs:
"timestamp": "${{ github.event.pull_request.updated_at }}"
}
]
uses: Ilshidur/action-discord@0.4.0
with:
args: ${{ env.TEST_STATUS == '✅ All tests passed, nice ka pre👌' && 'Ikaw na jud dong. 🚀' || 'Please ko fix today. Salamat' }}
DISCORD_MESSAGE: ${{ env.TEST_STATUS == '✅ All tests passed, nice ka pre👌' && 'Ikaw na jud dong. 🚀' || 'Please ko fix today. Salamat' }}
run: |
payload=$(jq -nc \
--arg username "$DISCORD_USERNAME" \
--arg avatar_url "$DISCORD_AVATAR" \
--arg content "$DISCORD_MESSAGE" \
--argjson embeds "$DISCORD_EMBEDS" \
'{username: $username, avatar_url: $avatar_url, content: $content, embeds: $embeds}')

if [ -z "$DISCORD_WEBHOOK" ]; then
echo "DISCORD_PR_WEBHOOK is not configured; skipping Discord notification."
exit 0
fi

curl \
--fail \
--show-error \
--silent \
-H "Content-Type: application/json" \
-d "$payload" \
"$DISCORD_WEBHOOK"
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,9 @@ Required environment variables (see `.env.sample`):

Optional:

- `JWT_ACCESS_TOKEN_EXPIRY`: Access token lifetime (default: `300s`)
- `JWT_REFRESH_TOKEN_EXPIRY`: Refresh token lifetime (default: `30d`)
- `JWT_BCRYPT_ROUNDS`: Bcrypt cost factor for refresh-token hashing (default: `10`; values below `10` log a warning outside production)
- `OPENAPI_MODE`: Set to `"true"` to enable Swagger docs (default: disabled)
- `SYNC_ON_STARTUP`: Set to `"true"` to run Course and Enrollment sync on startup (default: disabled)
- `SUPER_ADMIN_USERNAME`: Default super admin username (default: `superadmin`)
Expand Down
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,9 @@ Required environment variables (see `.env.sample`):

Optional:

- `JWT_ACCESS_TOKEN_EXPIRY`: Access token lifetime (default: `300s`)
- `JWT_REFRESH_TOKEN_EXPIRY`: Refresh token lifetime (default: `30d`)
- `JWT_BCRYPT_ROUNDS`: Bcrypt cost factor for refresh-token hashing (default: `10`; values below `10` warn outside production)
- `OPENAPI_MODE`: Set to `"true"` to enable Swagger docs (default: disabled)
- `SYNC_ON_STARTUP`: Set to `"true"` to run Course and Enrollment sync on startup (default: disabled)
- `SUPER_ADMIN_USERNAME`: Default super admin username (default: `superadmin`)
Expand Down
2 changes: 2 additions & 0 deletions src/configurations/env/env.validation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from 'zod';
import { envSchema } from '.';
import { warnOnWeakJwtConfig } from './jwt.env';

export const validateEnv = (config: Record<string, unknown>) => {
const result = envSchema.safeParse(config);
Expand All @@ -10,5 +11,6 @@ export const validateEnv = (config: Record<string, unknown>) => {
process.exit(1);
}

warnOnWeakJwtConfig(result.data);
return result.data; // Return validated config for NestJS
};
4 changes: 3 additions & 1 deletion src/configurations/env/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { serverEnvSchema } from './server.env';
import { corsEnvSchema } from './cors.env';
import { DEFAULT_PORT } from '../common/constants';
import { databaseEnvSchema } from './database.env';
import { jwtEnvSchema } from './jwt.env';
import { jwtEnvSchema, warnOnWeakJwtConfig } from './jwt.env';
import { openaiEnvSchema } from './openai.env';
import { adminEnvSchema } from './admin.env';
import { redisEnvSchema } from './redis.env';
Expand All @@ -27,4 +27,6 @@ export type Env = z.infer<typeof envSchema>;

export const env = envSchema.parse(process.env);

warnOnWeakJwtConfig(env);

export const envPortResolve = () => env.PORT ?? DEFAULT_PORT;
70 changes: 70 additions & 0 deletions src/configurations/env/jwt-duration.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
const JWT_DURATION_PATTERN =
/^\s*(?<value>-?(?:\d+\.?\d*|\.\d+))\s*(?<unit>milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)?\s*$/i;

const JWT_DURATION_MULTIPLIERS: Record<string, number> = {
ms: 1,
msec: 1,
msecs: 1,
millisecond: 1,
milliseconds: 1,
s: 1000,
sec: 1000,
secs: 1000,
second: 1000,
seconds: 1000,
m: 60 * 1000,
min: 60 * 1000,
mins: 60 * 1000,
minute: 60 * 1000,
minutes: 60 * 1000,
h: 60 * 60 * 1000,
hr: 60 * 60 * 1000,
hrs: 60 * 60 * 1000,
hour: 60 * 60 * 1000,
hours: 60 * 60 * 1000,
d: 24 * 60 * 60 * 1000,
day: 24 * 60 * 60 * 1000,
days: 24 * 60 * 60 * 1000,
w: 7 * 24 * 60 * 60 * 1000,
week: 7 * 24 * 60 * 60 * 1000,
weeks: 7 * 24 * 60 * 60 * 1000,
y: 365.25 * 24 * 60 * 60 * 1000,
yr: 365.25 * 24 * 60 * 60 * 1000,
yrs: 365.25 * 24 * 60 * 60 * 1000,
year: 365.25 * 24 * 60 * 60 * 1000,
years: 365.25 * 24 * 60 * 60 * 1000,
};

export const parseJwtDurationToMilliseconds = (
value: string,
): number | null => {
const match = JWT_DURATION_PATTERN.exec(value);

if (!match?.groups) {
return null;
}

const durationValue = Number.parseFloat(match.groups.value);

if (!Number.isFinite(durationValue) || durationValue <= 0) {
return null;
}

const unit = (match.groups.unit ?? 'ms').toLowerCase();
const multiplier = JWT_DURATION_MULTIPLIERS[unit];

if (!multiplier) {
return null;
}

const milliseconds = durationValue * multiplier;

if (!Number.isFinite(milliseconds) || milliseconds <= 0) {
return null;
}

return Math.floor(milliseconds);
};

export const isValidJwtDuration = (value: string): boolean =>
parseJwtDurationToMilliseconds(value) !== null;
80 changes: 80 additions & 0 deletions src/configurations/env/jwt.env.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { jwtEnvSchema, warnOnWeakJwtConfig } from './jwt.env';

describe('jwtEnvSchema', () => {
const originalWarn = console.warn;

beforeEach(() => {
console.warn = jest.fn();
});

afterEach(() => {
console.warn = originalWarn;
});

it('applies defaults for optional JWT settings', () => {
const result = jwtEnvSchema.parse({
JWT_SECRET: 'secret',
REFRESH_SECRET: 'refresh-secret',
});

expect(result.JWT_ACCESS_TOKEN_EXPIRY).toBe('300s');
expect(result.JWT_REFRESH_TOKEN_EXPIRY).toBe('30d');
expect(result.JWT_BCRYPT_ROUNDS).toBe(10);
});

it('rejects invalid duration strings', () => {
expect(() =>
jwtEnvSchema.parse({
JWT_SECRET: 'secret',
REFRESH_SECRET: 'refresh-secret',
JWT_ACCESS_TOKEN_EXPIRY: 'later',
}),
).toThrow();

expect(() =>
jwtEnvSchema.parse({
JWT_SECRET: 'secret',
REFRESH_SECRET: 'refresh-secret',
JWT_REFRESH_TOKEN_EXPIRY: '0d',
}),
).toThrow();
});

it('rejects non-positive bcrypt rounds', () => {
expect(() =>
jwtEnvSchema.parse({
JWT_SECRET: 'secret',
REFRESH_SECRET: 'refresh-secret',
JWT_BCRYPT_ROUNDS: '0',
}),
).toThrow();

expect(() =>
jwtEnvSchema.parse({
JWT_SECRET: 'secret',
REFRESH_SECRET: 'refresh-secret',
JWT_BCRYPT_ROUNDS: '1.5',
}),
).toThrow();
});

it('warns for weak bcrypt rounds outside production', () => {
warnOnWeakJwtConfig({
NODE_ENV: 'development',
JWT_BCRYPT_ROUNDS: 8,
});

expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining('JWT_BCRYPT_ROUNDS'),
);
});

it('does not warn for weak bcrypt rounds in production', () => {
warnOnWeakJwtConfig({
NODE_ENV: 'production',
JWT_BCRYPT_ROUNDS: 8,
});

expect(console.warn).not.toHaveBeenCalled();
});
});
38 changes: 37 additions & 1 deletion src/configurations/env/jwt.env.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,44 @@
import z from 'zod';
import { isValidJwtDuration } from './jwt-duration.util';

let hasWarnedWeakJwtBcryptRounds = false;

export const jwtEnvSchema = z.object({
JWT_SECRET: z.string(),
REFRESH_SECRET: z.string(),
JWT_ACCESS_TOKEN_EXPIRY: z
.string()
.trim()
.refine(isValidJwtDuration, {
message: 'JWT_ACCESS_TOKEN_EXPIRY must be a valid positive duration',
})
.default('300s'),
JWT_REFRESH_TOKEN_EXPIRY: z
.string()
.trim()
.refine(isValidJwtDuration, {
message: 'JWT_REFRESH_TOKEN_EXPIRY must be a valid positive duration',
})
.default('30d'),
JWT_BCRYPT_ROUNDS: z.coerce.number().int().positive().default(10),
});

export type DatabaseEnv = z.infer<typeof jwtEnvSchema>;
export type JwtEnv = z.infer<typeof jwtEnvSchema>;

export const warnOnWeakJwtConfig = (config: {
NODE_ENV: 'development' | 'production' | 'test';
JWT_BCRYPT_ROUNDS: number;
}) => {
if (
config.NODE_ENV === 'production' ||
config.JWT_BCRYPT_ROUNDS >= 10 ||
hasWarnedWeakJwtBcryptRounds
) {
return;
}

hasWarnedWeakJwtBcryptRounds = true;
console.warn(
`JWT_BCRYPT_ROUNDS is set to ${config.JWT_BCRYPT_ROUNDS}. Values below 10 reduce refresh-token hashing cost and should only be used for non-production convenience.`,
);
};
9 changes: 2 additions & 7 deletions src/entities/refresh-token.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,22 +37,17 @@ export class RefreshToken extends CustomBaseEntity {
userId: string,
metaData: RequestMetadata,
refreshId: string,
expiresAt: Date,
) {
const newRefreshToken = new RefreshToken();
newRefreshToken.id = refreshId;
newRefreshToken.tokenHash = hashedToken;
newRefreshToken.userId = userId;
newRefreshToken.expiresAt = RefreshToken.addDays(new Date(), 30);
newRefreshToken.expiresAt = expiresAt;
newRefreshToken.isActive = true;
newRefreshToken.browserName = metaData.browserName;
newRefreshToken.os = metaData.os;
newRefreshToken.ipAddress = metaData.ipAddress;
return newRefreshToken;
}

static addDays(date: Date, days: number): Date {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
}
Loading
Loading