Authentication
Overview
BookWish uses JWT (JSON Web Tokens) for stateless authentication. The system supports both guest and registered users with different tier levels.
Authentication Flow
1. Guest User Creation
Client → POST /auth/guest
{ deviceId: "..." }
← { user, accessToken, refreshToken }
Guest users can:
- Create wishlists
- Add books to wishlists
- Write private notes
- Browse the catalog
Guest users cannot:
- Place orders
- Follow users
- Post lines/reviews
- Join clubs/challenges
2. User Registration
Client → POST /auth/signup
{ email, password, displayName }
← { user, accessToken, refreshToken }
Creates a new registered user account with free tier.
3. User Login
Client → POST /auth/login
{ email, password }
← { user, accessToken, refreshToken }
Authenticates existing user and returns tokens.
4. Guest Migration
Client → POST /auth/migrate-guest
Authorization: Bearer {accessToken}
{ email, password, displayName }
← { user, accessToken, refreshToken }
Converts a guest account to a full registered account while preserving all data (wishlists, notes, addresses).
5. Token Refresh
Client → POST /auth/refresh
{ refreshToken }
← { accessToken, refreshToken }
Exchanges a refresh token for new access and refresh tokens. The old refresh token is blacklisted.
6. Logout
Client → POST /auth/logout
Authorization: Bearer {accessToken}
{ refreshToken }
← 204 No Content
Blacklists the refresh token to prevent reuse.
JWT Token Structure
Access Token
Payload:
{
userId: string;
isGuest: boolean;
tier: UserTier; // guest | free | premium | bookstore | admin
iat: number; // Issued at (seconds since epoch)
exp: number; // Expires at (seconds since epoch)
}
Properties:
- Lifetime: 15 minutes
- Secret:
JWT_SECRETenvironment variable - Purpose: Authorize API requests
Refresh Token
Payload:
{
userId: string;
isGuest: boolean;
tier: UserTier;
iat: number;
exp: number;
}
Properties:
- Lifetime: 7 days
- Secret:
JWT_REFRESH_SECRETenvironment variable - Purpose: Obtain new access tokens
Token Generation
// src/utils/jwt.ts
import jwt from 'jsonwebtoken';
import { env } from '../config/env';
export interface TokenPayload {
userId: string;
isGuest: boolean;
tier: UserTier;
}
export function signAccessToken(payload: TokenPayload): string {
return jwt.sign(payload, env.JWT_SECRET, {
expiresIn: '15m',
});
}
export function signRefreshToken(payload: TokenPayload): string {
return jwt.sign(payload, env.JWT_REFRESH_SECRET, {
expiresIn: '7d',
});
}
Token Verification
export function verifyAccessToken(token: string): TokenPayload {
const decoded = jwt.verify(token, env.JWT_SECRET) as TokenPayload;
return decoded;
}
export function verifyRefreshToken(token: string): TokenPayload {
const decoded = jwt.verify(token, env.JWT_REFRESH_SECRET) as TokenPayload;
return decoded;
}
Authentication Middleware
Required Authentication
// src/middleware/auth.middleware.ts
export async function authenticate(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({
error: 'Unauthorized',
message: 'Missing or invalid authorization header'
});
return;
}
const token = authHeader.substring(7);
try {
const payload = verifyAccessToken(token);
// Always fetch current tier from database (not from token cache)
const user = await prisma.user.findUnique({
where: { id: payload.userId },
select: { id: true, isGuest: true, tier: true },
});
if (!user) {
res.status(401).json({
error: 'Unauthorized',
message: 'User not found'
});
return;
}
req.user = {
id: user.id,
isGuest: user.isGuest,
tier: user.tier,
};
next();
} catch (error) {
res.status(401).json({
error: 'Unauthorized',
message: 'Invalid or expired token'
});
}
}
Usage:
router.post('/protected', authenticate, controller.handler);
Optional Authentication
export async function optionalAuth(
req: Request,
_res: Response,
next: NextFunction
): Promise<void> {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
req.user = undefined;
next();
return;
}
const token = authHeader.substring(7);
try {
const payload = verifyAccessToken(token);
const user = await prisma.user.findUnique({
where: { id: payload.userId },
select: { id: true, isGuest: true, tier: true },
});
if (user) {
req.user = {
id: user.id,
isGuest: user.isGuest,
tier: user.tier,
};
} else {
req.user = undefined;
}
} catch (error) {
req.user = undefined;
}
next();
}
Usage:
router.get('/public-endpoint', optionalAuth, controller.handler);
Token Blacklisting
Refresh tokens are blacklisted in Redis when:
- User logs out
- Refresh token is used to get new tokens
// src/services/auth.service.ts
export async function logout(
_userId: string,
refreshToken: string
): Promise<void> {
// Blacklist token for 7 days (matches token expiry)
await redis.setex(`blacklist:${refreshToken}`, 7 * 24 * 60 * 60, '1');
}
export async function refreshTokens(
refreshToken: string
): Promise<TokenPair> {
const payload = verifyRefreshToken(refreshToken);
// Check if token is blacklisted
const isBlacklisted = await redis.get(`blacklist:${refreshToken}`);
if (isBlacklisted) {
throw new Error('Token has been revoked');
}
// ... fetch user and generate new tokens ...
// Blacklist old refresh token
await redis.setex(`blacklist:${refreshToken}`, 7 * 24 * 60 * 60, '1');
return { accessToken, refreshToken };
}
User Tiers
enum UserTier {
guest // Temporary accounts (limited features)
free // Free registered accounts (3 wishlist limit)
premium // Premium subscribers (unlimited wishlists)
bookstore // Bookstore owners (store management)
admin // BookWish admins (full access)
}
Tier Hierarchy
const tierHierarchy: Record<UserTier, number> = {
guest: 0,
free: 1,
premium: 2,
bookstore: 3,
admin: 4,
};
Password Hashing
// src/utils/password.ts
import bcrypt from 'bcrypt';
const SALT_ROUNDS = 10;
export async function hashPassword(password: string): Promise<string> {
return await bcrypt.hash(password, SALT_ROUNDS);
}
export async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
return await bcrypt.compare(password, hash);
}
Request Context
After authentication middleware runs, controllers have access to:
req.user: {
id: string;
isGuest: boolean;
tier: UserTier;
} | undefined
Example:
async handler(req: Request, res: Response) {
if (!req.user) {
return res.status(401).json({ error: 'Unauthorized' });
}
const userId = req.user.id;
const tier = req.user.tier;
// ...
}
Client Implementation
Initial Setup
// Client-side (React, Flutter, etc.)
// 1. Create guest user on first launch
const { user, accessToken, refreshToken } = await POST('/auth/guest', {
deviceId: getDeviceId()
});
// Store tokens securely
await SecureStorage.set('accessToken', accessToken);
await SecureStorage.set('refreshToken', refreshToken);
Making Authenticated Requests
// Add Authorization header to all requests
const response = await fetch('/api/endpoint', {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
});
Handling Token Expiry
async function apiRequest(endpoint: string, options: RequestOptions) {
let accessToken = await SecureStorage.get('accessToken');
const response = await fetch(endpoint, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${accessToken}`,
},
});
// If 401, try to refresh token
if (response.status === 401) {
const refreshToken = await SecureStorage.get('refreshToken');
const refreshResponse = await POST('/auth/refresh', { refreshToken });
const { accessToken: newAccessToken, refreshToken: newRefreshToken } = refreshResponse;
// Store new tokens
await SecureStorage.set('accessToken', newAccessToken);
await SecureStorage.set('refreshToken', newRefreshToken);
// Retry original request with new token
return fetch(endpoint, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${newAccessToken}`,
},
});
}
return response;
}
Migrating Guest to Full Account
// When user wants to sign up
const { user, accessToken, refreshToken } = await POST('/auth/migrate-guest', {
email: 'user@example.com',
password: 'securepassword',
displayName: 'John Doe',
}, {
headers: {
'Authorization': `Bearer ${currentAccessToken}`,
},
});
// Update stored tokens
await SecureStorage.set('accessToken', accessToken);
await SecureStorage.set('refreshToken', refreshToken);
Security Best Practices
- Use HTTPS - All authentication requests must use HTTPS
- Secure Token Storage - Store tokens in secure storage (not localStorage)
- Short Access Token Lifetime - 15 minutes limits exposure if compromised
- Blacklist Refresh Tokens - Prevent token reuse after logout/refresh
- Validate Tokens on Every Request - Fetch fresh user tier from database
- Use Strong Secrets - Use cryptographically random JWT secrets
- Rate Limit Auth Endpoints - Prevent brute force attacks
- Hash Passwords - Use bcrypt with appropriate salt rounds
- Never Log Tokens - Don't log tokens in application logs
- Expire Blacklist - TTL on blacklist matches token expiry
Environment Variables
# JWT secrets (use long random strings)
JWT_SECRET=your-secret-key-here
JWT_REFRESH_SECRET=your-refresh-secret-key-here
# Token lifetimes
JWT_ACCESS_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
# Redis for token blacklist
REDIS_URL=redis://localhost:6379
Testing Authentication
import { signAccessToken, verifyAccessToken } from '../utils/jwt';
describe('JWT Tokens', () => {
it('should sign and verify access token', () => {
const payload = {
userId: 'user-123',
isGuest: false,
tier: 'free',
};
const token = signAccessToken(payload);
const decoded = verifyAccessToken(token);
expect(decoded.userId).toBe(payload.userId);
expect(decoded.tier).toBe(payload.tier);
});
it('should reject expired token', () => {
// Create token with 0 expiry
const token = jwt.sign({ userId: '123' }, JWT_SECRET, { expiresIn: 0 });
expect(() => verifyAccessToken(token)).toThrow();
});
});