Skip to main content

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_SECRET environment 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_SECRET environment 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:

  1. User logs out
  2. 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

  1. Use HTTPS - All authentication requests must use HTTPS
  2. Secure Token Storage - Store tokens in secure storage (not localStorage)
  3. Short Access Token Lifetime - 15 minutes limits exposure if compromised
  4. Blacklist Refresh Tokens - Prevent token reuse after logout/refresh
  5. Validate Tokens on Every Request - Fetch fresh user tier from database
  6. Use Strong Secrets - Use cryptographically random JWT secrets
  7. Rate Limit Auth Endpoints - Prevent brute force attacks
  8. Hash Passwords - Use bcrypt with appropriate salt rounds
  9. Never Log Tokens - Don't log tokens in application logs
  10. 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();
});
});