- Introduced support for previous JWT secrets in the environment configuration to facilitate key rotation. - Refactored the JoseJwtService to manage multiple signing and verification keys, improving security during token validation. - Updated the AuthTokenService to include family identifiers for refresh tokens, enhancing session management and security. - Modified the PasswordWorkflowService and SignupWorkflowService to return session metadata instead of token strings, aligning with security best practices. - Improved error handling and token revocation logic in the TokenBlacklistService and AuthTokenService to prevent replay attacks. - Updated documentation to reflect changes in the authentication architecture and security model.
7.4 KiB
7.4 KiB
Auth Schema Improvements - Explanation
Problems Identified and Fixed
1. z.unknown() Type Safety Issue ❌ → ✅
Problem:
// BEFORE - NO TYPE SAFETY
export const signupResultSchema = z.object({
user: z.unknown(), // ❌ Loses all type information and validation
tokens: authTokensSchema,
});
Why it was wrong:
z.unknown()provides zero validation at runtime- TypeScript can't infer proper types from it
- Defeats the purpose of schema-first architecture
- Any object could pass validation, even malformed data
Solution:
// AFTER - FULL TYPE SAFETY
export const userProfileSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
role: z.enum(["USER", "ADMIN"]),
// ... all fields properly typed
});
export const signupResultSchema = z.object({
user: userProfileSchema, // ✅ Full validation and type inference
tokens: authTokensSchema,
});
Benefits:
- Runtime validation of user data structure
- Proper TypeScript type inference
- Catches invalid data early
- Self-documenting API contract
2. Email Validation Issues ❌ → ✅
Problem:
The z.string().email() in checkPasswordNeededResponseSchema was correct, but using z.unknown() elsewhere meant emails weren't being validated in user objects.
Solution:
export const userProfileSchema = z.object({
email: z.string().email(), // ✅ Validates email format
// ...
});
Now all user objects have their emails properly validated.
3. UserProfile Alias Confusion 🤔 → ✅
Problem:
export type UserProfile = AuthenticatedUser;
This created confusion about why we have two names for the same thing.
Solution - Added Clear Documentation:
/**
* UserProfile type alias
*
* Note: This is an alias for backward compatibility.
* Both types represent the same thing: a complete user profile with auth state.
*
* Architecture:
* - PortalUser: Only auth state from portal DB (id, email, role, emailVerified, etc.)
* - CustomerProfile: Profile data from WHMCS (firstname, lastname, address, etc.)
* - AuthenticatedUser/UserProfile: CustomerProfile + auth state = complete user
*/
export type UserProfile = AuthenticatedUser;
Why the alias exists:
- Backward compatibility: Code may use either name
- Domain language: "UserProfile" is more business-friendly than "AuthenticatedUser"
- Convention: Many authentication libraries use "UserProfile"
Architecture Explanation
Three-Layer User Model
┌─────────────────────────────────────────────┐
│ AuthenticatedUser / UserProfile │
│ (Complete User) │
│ ┌────────────────────────────────────────┐ │
│ │ CustomerProfile (WHMCS) │ │
│ │ - firstname, lastname │ │
│ │ - address, phone │ │
│ │ - language, currency │ │
│ └────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────┐ │
│ │ PortalUser (Portal DB) │ │
│ │ - id, email │ │
│ │ - role, emailVerified, mfaEnabled │ │
│ │ - lastLoginAt │ │
│ └────────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
1. PortalUser (Portal Database)
- Purpose: Authentication state only
- Source: Portal's Prisma database
- Fields: id, email, role, emailVerified, mfaEnabled, lastLoginAt
- Schema:
portalUserSchema - Use Cases: JWT validation, token refresh, auth checks
2. CustomerProfile (WHMCS)
- Purpose: Business profile data
- Source: WHMCS API (single source of truth for profile data)
- Fields: firstname, lastname, address, phone, language, currency
- Schema: Interface only (external system)
- Use Cases: Displaying user info, profile updates
3. AuthenticatedUser / UserProfile (Combined)
- Purpose: Complete user representation
- Source: Portal DB + WHMCS (merged)
- Fields: All fields from PortalUser + CustomerProfile
- Schema:
userProfileSchema(for validation) - Use Cases: API responses, full user context
Why This Matters
Before (with z.unknown()):
// Could pass completely invalid data ❌
const badData = {
user: { totally: "wrong", structure: true },
tokens: {
/* ... */
},
};
// Would validate successfully! ❌
After (with proper schema):
// Validates structure correctly ✅
const goodData = {
user: {
id: "uuid-here",
email: "valid@email.com",
role: "USER",
// ... all required fields
},
tokens: {
/* ... */
},
};
// Validates ✅
const badData = {
user: { wrong: "structure" },
tokens: {
/* ... */
},
};
// Throws validation error ✅
Implementation Details
Schema Definition Location
The userProfileSchema is defined before it's used:
// 1. First, define the schema
export const userProfileSchema = z.object({
// All fields defined
});
// 2. Then use it in response schemas
export const authResponseSchema = z.object({
user: userProfileSchema, // ✅ Now defined
tokens: authTokensSchema,
});
Type Inference
TypeScript now properly infers types:
// Type is inferred from schema
type InferredUser = z.infer<typeof userProfileSchema>;
// Results in proper type:
// {
// id: string;
// email: string;
// role: "USER" | "ADMIN";
// emailVerified: boolean;
// // ... etc
// }
Migration Impact
⚠️ Potential Breaking Change (Security Improvement)
Auth endpoints now return session metadata instead of token strings:
- Before:
{ user, tokens: { accessToken, refreshToken, expiresAt, refreshExpiresAt } } - After:
{ user, session: { expiresAt, refreshExpiresAt } }
Token strings are delivered via httpOnly cookies only.
✅ Improved Type Safety
- Better autocomplete in IDEs
- Compile-time error checking
- Runtime validation of API responses
✅ Better Documentation
- Schema serves as living documentation
- Clear separation of concerns
- Self-describing data structures
Summary
| Issue | Before | After |
|---|---|---|
| User validation | z.unknown() ❌ |
userProfileSchema ✅ |
| Type safety | None | Full |
| Runtime validation | None | Complete |
| Documentation | Unclear | Self-documenting |
| Email validation | Partial | Complete |
| UserProfile clarity | Confusing | Documented |
Result: Proper schema-first architecture with full type safety and validation! 🎉