Implement security improvements: enforce JWT secrets, remove client-side refresh token storage

Co-authored-by: alexpolo1 <14327609+alexpolo1@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-01-06 07:20:24 +00:00
parent 7d8ba8cd60
commit be642f5c1d
5 changed files with 67 additions and 24 deletions

View File

@@ -15,9 +15,12 @@ Quick reference for all files in this project.
| File | Purpose | Security |
|------|---------|----------|
| `apikey` | API authentication token | 🔒 SECRET |
| `apikey` | API authentication token | 🔒 SECRET - **DO NOT COMMIT** |
| `apikey.example` | Example template for apikey | Safe to commit |
| `apiendpoint` | Production API endpoint URL | Public |
**⚠️ SECURITY WARNING:** The `apikey` file contains sensitive credentials and must NEVER be committed to version control. Use `apikey.example` as a template to create your local `apikey` file.
## 🔧 Test Scripts (18 files)
All scripts located in `examples/` directory:
@@ -178,7 +181,7 @@ curl -X POST https://graphql.ordrestyring.dk/graphql \
## ⚠️ Important Notes
1. **API Key:** Keep `apikey` file secure, never commit to version control
1. **API Key:** The `apikey` file contains sensitive credentials and must NEVER be committed to version control. Use `apikey.example` as a template to create your local `apikey` file.
2. **Production:** All tests run against production, be careful with writes
3. **Cleanup:** Write operations include cleanup steps
4. **Outputs:** Contain real data, may include sensitive information

2
apitest/apikey.example Normal file
View File

@@ -0,0 +1,2 @@
# Put your API key here
API_KEY=REPLACE_ME

11
backend/.env.example Normal file
View File

@@ -0,0 +1,11 @@
# JWT Secrets - REQUIRED
# Generate strong random secrets for production (e.g., using: openssl rand -base64 32)
JWT_ACCESS_SECRET=REPLACE_ME
JWT_REFRESH_SECRET=REPLACE_ME
# OpenAI API Key - REQUIRED for AI features
OPENAI_ADMIN_KEY=REPLACE_ME
# Optional: Token expiry configuration
# JWT_EXPIRY=24h
# REFRESH_TOKEN_EXPIRY=7d

View File

@@ -1,10 +1,30 @@
const jwt = require('jsonwebtoken');
// JWT secret - should be in .env in production
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production';
// JWT secrets - REQUIRED in production, fail-fast if not set
const JWT_ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
// Fail-fast: Require both secrets to be set at startup
if (!JWT_ACCESS_SECRET) {
throw new Error('FATAL: JWT_ACCESS_SECRET environment variable is required. Set it in your .env file.');
}
if (!JWT_REFRESH_SECRET) {
throw new Error('FATAL: JWT_REFRESH_SECRET environment variable is required. Set it in your .env file.');
}
// Token expiry configuration
const JWT_EXPIRY = process.env.JWT_EXPIRY || '24h';
const REFRESH_TOKEN_EXPIRY = process.env.REFRESH_TOKEN_EXPIRY || '7d';
/**
* SECURITY NOTES:
* - Access tokens should be short-lived (default: 24h)
* - Refresh tokens should be stored server-side (database) or as HttpOnly cookies
* - Refresh tokens should be rotated on each use to prevent replay attacks
* - Never store refresh tokens in localStorage or any client-side storage
* - Consider implementing refresh token families for better security
*/
/**
* Middleware to verify JWT token
*/
@@ -20,7 +40,7 @@ function verifyToken(req, res, next) {
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
const decoded = jwt.verify(token, JWT_ACCESS_SECRET);
req.user = decoded;
next();
} catch (error) {
@@ -47,13 +67,15 @@ function generateAccessToken(user) {
username: user.username,
id: user.id
},
JWT_SECRET,
JWT_ACCESS_SECRET,
{ expiresIn: JWT_EXPIRY }
);
}
/**
* Generate refresh token
* NOTE: Refresh tokens MUST be stored server-side (in database) or as HttpOnly cookies.
* They should be rotated on each use to prevent replay attacks.
*/
function generateRefreshToken(user) {
return jwt.sign(
@@ -62,17 +84,19 @@ function generateRefreshToken(user) {
id: user.id,
type: 'refresh'
},
JWT_SECRET,
JWT_REFRESH_SECRET,
{ expiresIn: REFRESH_TOKEN_EXPIRY }
);
}
/**
* Verify refresh token and return user data
* NOTE: In production, refresh tokens should be validated against a server-side store
* and rotated on each use.
*/
function verifyRefreshToken(token) {
try {
const decoded = jwt.verify(token, JWT_SECRET);
const decoded = jwt.verify(token, JWT_REFRESH_SECRET);
if (decoded.type !== 'refresh') {
throw new Error('Invalid token type');
}
@@ -87,5 +111,6 @@ module.exports = {
generateAccessToken,
generateRefreshToken,
verifyRefreshToken,
JWT_SECRET
JWT_ACCESS_SECRET,
JWT_REFRESH_SECRET
};

View File

@@ -17,40 +17,42 @@ export const useAuth = () => {
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [accessToken, setAccessToken] = useState(null);
const [refreshToken, setRefreshToken] = useState(null);
// NOTE: refreshToken state removed - refresh tokens should be stored as HttpOnly cookies
const [loading, setLoading] = useState(true);
// Load tokens from localStorage on mount
// TODO: Remove refreshToken from localStorage - it should be stored as HttpOnly cookie
useEffect(() => {
const storedAccessToken = localStorage.getItem('accessToken');
const storedRefreshToken = localStorage.getItem('refreshToken');
const storedUser = localStorage.getItem('user');
if (storedAccessToken && storedRefreshToken && storedUser) {
if (storedAccessToken && storedUser) {
setAccessToken(storedAccessToken);
setRefreshToken(storedRefreshToken);
setUser(JSON.parse(storedUser));
}
setLoading(false);
}, []);
// Set up axios interceptor for token refresh
// TODO: Backend must set refresh token as HttpOnly cookie on login/refresh
// TODO: Implement server-side refresh token persistence and rotation in follow-up PR
useEffect(() => {
const interceptor = axios.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// If 401 and we have a refresh token, try refreshing
// If 401 with expired flag, try refreshing using cookie-based flow
if (error.response?.status === 401 &&
error.response?.data?.expired &&
refreshToken &&
!originalRequest._retry) {
originalRequest._retry = true;
try {
const response = await axios.post(`${API_BASE_URL}/api/auth/refresh`, {
refreshToken
// Call refresh endpoint WITHOUT sending refreshToken in body
// Server should read refresh token from HttpOnly cookie
const response = await axios.post(`${API_BASE_URL}/api/auth/refresh`, {}, {
withCredentials: true // Important: send cookies with request
});
const { accessToken: newAccessToken } = response.data;
@@ -74,7 +76,7 @@ export const AuthProvider = ({ children }) => {
return () => {
axios.interceptors.response.eject(interceptor);
};
}, [refreshToken]);
}, []); // Removed refreshToken dependency
const login = async (username, password) => {
try {
@@ -82,19 +84,20 @@ export const AuthProvider = ({ children }) => {
username,
password
}, {
timeout: 10000
timeout: 10000,
withCredentials: true // Important: receive HttpOnly cookie with refresh token
});
if (response.data.success) {
const { accessToken: newAccessToken, refreshToken: newRefreshToken } = response.data;
// TODO: Backend should set refreshToken as HttpOnly cookie, not send it in response
const { accessToken: newAccessToken } = response.data;
const userData = { username };
setAccessToken(newAccessToken);
setRefreshToken(newRefreshToken);
setUser(userData);
// Only store access token in localStorage, NOT refresh token
localStorage.setItem('accessToken', newAccessToken);
localStorage.setItem('refreshToken', newRefreshToken);
localStorage.setItem('user', JSON.stringify(userData));
// Set default auth header
@@ -116,9 +119,8 @@ export const AuthProvider = ({ children }) => {
const logout = () => {
setUser(null);
setAccessToken(null);
setRefreshToken(null);
// No need to remove refreshToken from localStorage (it's not stored there anymore)
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem('user');
localStorage.removeItem('isAuthenticated'); // Legacy cleanup
delete axios.defaults.headers.common['Authorization'];