Implemented secure refresh token rotation with the following features: - RefreshToken domain entity with IsExpired(), IsRevoked(), IsActive(), Revoke() methods - IRefreshTokenService with token generation, rotation, and revocation - RefreshTokenService with SHA-256 hashing and token family tracking - RefreshTokenRepository for database operations - Database migration for refresh_tokens table with proper indexes - Updated LoginCommandHandler and RegisterTenantCommandHandler to return refresh tokens - Added POST /api/auth/refresh endpoint (token rotation) - Added POST /api/auth/logout endpoint (revoke single token) - Added POST /api/auth/logout-all endpoint (revoke all user tokens) - Updated JWT access token expiration to 15 minutes (from 60) - Refresh token expiration set to 7 days - Security features: token reuse detection, IP address tracking, user-agent logging Changes: - Domain: RefreshToken.cs, IRefreshTokenRepository.cs - Application: IRefreshTokenService.cs, updated LoginResponseDto and RegisterTenantResult - Infrastructure: RefreshTokenService.cs, RefreshTokenRepository.cs, RefreshTokenConfiguration.cs - API: AuthController.cs (3 new endpoints), RefreshTokenRequest.cs, LogoutRequest.cs - Configuration: appsettings.Development.json (updated JWT settings) - DI: DependencyInjection.cs (registered new services) - Migration: AddRefreshTokens migration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
58 KiB
Day 5 Architecture Design: Advanced Authentication & Authorization
Date: 2025-11-03 Author: System Architect Status: Ready for Implementation
Executive Summary
This document provides comprehensive technical architecture for Day 5 development, focusing on three core security features:
- Refresh Token Mechanism (Priority 1)
- Role-Based Authorization (RBAC) (Priority 1)
- Email Verification Flow (Priority 2)
All designs are tailored for the existing .NET 9 + Clean Architecture + Multi-tenant system, with forward compatibility for future MCP Server integration.
Table of Contents
- 1. Refresh Token Mechanism
- 2. Role-Based Authorization (RBAC)
- 3. Email Verification Flow
- 4. Risk Assessment
- 5. Implementation Roadmap
- 6. MCP Integration Considerations
1. Refresh Token Mechanism
1.1 Background & Goals
Problem: Current JWT access tokens expire after 60 minutes, requiring users to re-login frequently. This degrades user experience and security.
Goals:
- Implement secure refresh token rotation
- Support long-lived sessions (7-30 days)
- Enable token revocation for security incidents
- Prepare for distributed session management
1.2 Architecture Design
1.2.1 Token Flow Diagram
┌─────────────┐ ┌─────────────┐
│ Client │ │ API Server │
└──────┬──────┘ └──────┬──────┘
│ │
│ 1. Login (credentials) │
├────────────────────────────────>│
│ │
│ 2. Access Token (60 min) │
│ Refresh Token (7 days) │
│<────────────────────────────────┤
│ │
│ 3. API Request + Access Token │
├────────────────────────────────>│
│ │
│ 4. Response (200 OK) │
│<────────────────────────────────┤
│ │
│ [After 60 minutes] │
│ │
│ 5. API Request + Expired Token │
├────────────────────────────────>│
│ │
│ 6. 401 Unauthorized │
│<────────────────────────────────┤
│ │
│ 7. Refresh Token Request │
├────────────────────────────────>│
│ │
│ 8. New Access Token (60 min) │
│ New Refresh Token (7 days) │
│<────────────────────────────────┤
│ │
1.2.2 Technology Decision: Database vs Redis
Comparison:
| Criteria | PostgreSQL | Redis |
|---|---|---|
| Performance | Good (indexed queries) | Excellent (in-memory) |
| Persistence | Native (ACID) | Optional (AOF/RDB) |
| Complexity | Low (existing stack) | Medium (new dependency) |
| Scalability | Vertical + Read Replicas | Horizontal + Clustering |
| Query Capability | Rich (SQL) | Limited (Key-Value) |
| Cost | Included | Additional infrastructure |
Recommendation: PostgreSQL for MVP, Redis for Scale
Rationale:
- Day 5 MVP: Use PostgreSQL to minimize new dependencies
- PostgreSQL can handle 10K-100K users easily with proper indexing
- Redis migration path is straightforward when scaling is needed
- Reduces Day 5 complexity and deployment overhead
1.2.3 Database Schema Design
-- New table for refresh tokens
CREATE TABLE identity.refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token_hash VARCHAR(128) NOT NULL UNIQUE, -- SHA-256 hash of token
user_id UUID NOT NULL,
tenant_id UUID NOT NULL,
-- Token metadata
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
revoked_at TIMESTAMP NULL,
revoked_reason VARCHAR(500) NULL,
-- Security tracking
ip_address VARCHAR(45) NULL, -- IPv6 compatible
user_agent VARCHAR(500) NULL,
last_used_at TIMESTAMP NULL,
-- Token family for rotation
token_family UUID NOT NULL, -- Group rotated tokens together
replaced_by_token_id UUID NULL, -- Link to next token in chain
-- Indexes
CONSTRAINT fk_refresh_tokens_user FOREIGN KEY (user_id)
REFERENCES identity.users(id) ON DELETE CASCADE,
CONSTRAINT fk_refresh_tokens_tenant FOREIGN KEY (tenant_id)
REFERENCES identity.tenants(id) ON DELETE CASCADE
);
-- Indexes for performance
CREATE INDEX idx_refresh_tokens_user_id ON identity.refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_tenant_id ON identity.refresh_tokens(tenant_id);
CREATE INDEX idx_refresh_tokens_token_hash ON identity.refresh_tokens(token_hash);
CREATE INDEX idx_refresh_tokens_expires_at ON identity.refresh_tokens(expires_at);
CREATE INDEX idx_refresh_tokens_token_family ON identity.refresh_tokens(token_family);
-- Cleanup expired tokens (scheduled job)
CREATE INDEX idx_refresh_tokens_cleanup
ON identity.refresh_tokens(expires_at, revoked_at)
WHERE revoked_at IS NULL;
1.2.4 Domain Model
RefreshToken Entity (Domain/Aggregates/Users/RefreshToken.cs):
public sealed class RefreshToken : Entity
{
public string TokenHash { get; private set; } = null!;
public UserId UserId { get; private set; } = null!;
public TenantId TenantId { get; private set; } = null!;
// Token lifecycle
public DateTime ExpiresAt { get; private set; }
public DateTime CreatedAt { get; private set; }
public DateTime? RevokedAt { get; private set; }
public string? RevokedReason { get; private set; }
// Security tracking
public string? IpAddress { get; private set; }
public string? UserAgent { get; private set; }
public DateTime? LastUsedAt { get; private set; }
// Token rotation
public Guid TokenFamily { get; private set; }
public Guid? ReplacedByTokenId { get; private set; }
// Factory method
public static RefreshToken Create(
UserId userId,
TenantId tenantId,
string tokenHash,
DateTime expiresAt,
Guid tokenFamily,
string? ipAddress = null,
string? userAgent = null)
{
return new RefreshToken
{
Id = Guid.NewGuid(),
TokenHash = tokenHash,
UserId = userId,
TenantId = tenantId,
ExpiresAt = expiresAt,
CreatedAt = DateTime.UtcNow,
TokenFamily = tokenFamily,
IpAddress = ipAddress,
UserAgent = userAgent
};
}
// Business methods
public void MarkAsUsed()
{
LastUsedAt = DateTime.UtcNow;
}
public void Revoke(string reason)
{
if (RevokedAt.HasValue)
throw new InvalidOperationException("Token already revoked");
RevokedAt = DateTime.UtcNow;
RevokedReason = reason;
}
public void MarkAsReplaced(Guid newTokenId)
{
ReplacedByTokenId = newTokenId;
RevokedAt = DateTime.UtcNow;
RevokedReason = "Rotated";
}
public bool IsValid()
{
return !RevokedAt.HasValue && DateTime.UtcNow < ExpiresAt;
}
public bool IsExpired()
{
return DateTime.UtcNow >= ExpiresAt;
}
}
1.2.5 Application Layer Design
Interface: Application/Services/IRefreshTokenService.cs
public interface IRefreshTokenService
{
Task<RefreshToken> GenerateRefreshTokenAsync(
User user,
string? ipAddress = null,
string? userAgent = null,
CancellationToken cancellationToken = default);
Task<(string AccessToken, RefreshToken NewRefreshToken)> RotateRefreshTokenAsync(
string refreshToken,
string? ipAddress = null,
string? userAgent = null,
CancellationToken cancellationToken = default);
Task RevokeTokenAsync(
string refreshToken,
string reason,
CancellationToken cancellationToken = default);
Task RevokeAllUserTokensAsync(
Guid userId,
string reason,
CancellationToken cancellationToken = default);
}
Implementation: Infrastructure/Services/RefreshTokenService.cs
public class RefreshTokenService : IRefreshTokenService
{
private readonly IUserRepository _userRepository;
private readonly IRefreshTokenRepository _refreshTokenRepository;
private readonly IJwtService _jwtService;
private readonly IConfiguration _configuration;
private readonly ILogger<RefreshTokenService> _logger;
public async Task<RefreshToken> GenerateRefreshTokenAsync(
User user,
string? ipAddress,
string? userAgent,
CancellationToken cancellationToken)
{
// Generate cryptographically secure token
var tokenBytes = new byte[64];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(tokenBytes);
var token = Convert.ToBase64String(tokenBytes);
// Hash token before storage (never store plain text)
var tokenHash = ComputeSha256Hash(token);
// Create refresh token
var expirationDays = _configuration.GetValue<int>("Jwt:RefreshTokenExpirationDays", 7);
var tokenFamily = Guid.NewGuid(); // New token family
var refreshToken = RefreshToken.Create(
userId: UserId.From(user.Id),
tenantId: user.TenantId,
tokenHash: tokenHash,
expiresAt: DateTime.UtcNow.AddDays(expirationDays),
tokenFamily: tokenFamily,
ipAddress: ipAddress,
userAgent: userAgent
);
await _refreshTokenRepository.AddAsync(refreshToken, cancellationToken);
_logger.LogInformation(
"Generated refresh token for user {UserId}, expires at {ExpiresAt}",
user.Id, refreshToken.ExpiresAt);
// Return token with plain text (only time we return plain text)
refreshToken.PlainTextToken = token; // Add transient property
return refreshToken;
}
public async Task<(string AccessToken, RefreshToken NewRefreshToken)> RotateRefreshTokenAsync(
string refreshToken,
string? ipAddress,
string? userAgent,
CancellationToken cancellationToken)
{
var tokenHash = ComputeSha256Hash(refreshToken);
// Find existing token
var existingToken = await _refreshTokenRepository
.GetByTokenHashAsync(tokenHash, cancellationToken);
if (existingToken == null)
{
_logger.LogWarning("Refresh token not found: {TokenHash}", tokenHash);
throw new UnauthorizedAccessException("Invalid refresh token");
}
// Check if token is valid
if (!existingToken.IsValid())
{
_logger.LogWarning(
"Invalid refresh token used by user {UserId}, token family {TokenFamily}",
existingToken.UserId, existingToken.TokenFamily);
// SECURITY: Revoke entire token family (possible token theft)
await RevokeTokenFamilyAsync(existingToken.TokenFamily, "Security: Reuse detected", cancellationToken);
throw new UnauthorizedAccessException("Token invalid or revoked");
}
// Get user and tenant
var user = await _userRepository.GetByIdAsync(existingToken.UserId.Value, cancellationToken);
if (user == null || user.Status != UserStatus.Active)
{
throw new UnauthorizedAccessException("User not found or inactive");
}
var tenant = await _tenantRepository.GetByIdAsync(existingToken.TenantId.Value, cancellationToken);
if (tenant == null || tenant.Status != TenantStatus.Active)
{
throw new UnauthorizedAccessException("Tenant not found or inactive");
}
// Generate new tokens
var newAccessToken = _jwtService.GenerateToken(user, tenant);
var newRefreshToken = await GenerateRefreshTokenForRotationAsync(
user,
existingToken.TokenFamily,
ipAddress,
userAgent,
cancellationToken);
// Mark old token as replaced
existingToken.MarkAsReplaced(newRefreshToken.Id);
await _refreshTokenRepository.UpdateAsync(existingToken, cancellationToken);
_logger.LogInformation(
"Rotated refresh token for user {UserId}, old token: {OldTokenId}, new token: {NewTokenId}",
user.Id, existingToken.Id, newRefreshToken.Id);
return (newAccessToken, newRefreshToken);
}
private async Task<RefreshToken> GenerateRefreshTokenForRotationAsync(
User user,
Guid tokenFamily,
string? ipAddress,
string? userAgent,
CancellationToken cancellationToken)
{
// Same as GenerateRefreshTokenAsync but reuses token family
var tokenBytes = new byte[64];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(tokenBytes);
var token = Convert.ToBase64String(tokenBytes);
var tokenHash = ComputeSha256Hash(token);
var expirationDays = _configuration.GetValue<int>("Jwt:RefreshTokenExpirationDays", 7);
var refreshToken = RefreshToken.Create(
userId: UserId.From(user.Id),
tenantId: user.TenantId,
tokenHash: tokenHash,
expiresAt: DateTime.UtcNow.AddDays(expirationDays),
tokenFamily: tokenFamily, // Reuse token family
ipAddress: ipAddress,
userAgent: userAgent
);
await _refreshTokenRepository.AddAsync(refreshToken, cancellationToken);
refreshToken.PlainTextToken = token;
return refreshToken;
}
private async Task RevokeTokenFamilyAsync(
Guid tokenFamily,
string reason,
CancellationToken cancellationToken)
{
var tokens = await _refreshTokenRepository
.GetByTokenFamilyAsync(tokenFamily, cancellationToken);
foreach (var token in tokens.Where(t => !t.RevokedAt.HasValue))
{
token.Revoke(reason);
}
await _refreshTokenRepository.UpdateRangeAsync(tokens, cancellationToken);
_logger.LogWarning(
"Revoked entire token family {TokenFamily}, reason: {Reason}",
tokenFamily, reason);
}
private static string ComputeSha256Hash(string input)
{
using var sha256 = SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(input);
var hash = sha256.ComputeHash(bytes);
return Convert.ToBase64String(hash);
}
}
1.2.6 API Endpoints
New endpoint: POST /api/auth/refresh
[HttpPost("refresh")]
[AllowAnonymous]
public async Task<ActionResult<LoginResponseDto>> RefreshToken(
[FromBody] RefreshTokenRequest request)
{
try
{
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
var userAgent = HttpContext.Request.Headers["User-Agent"].ToString();
var (accessToken, newRefreshToken) = await _refreshTokenService
.RotateRefreshTokenAsync(
request.RefreshToken,
ipAddress,
userAgent,
HttpContext.RequestAborted);
return Ok(new LoginResponseDto
{
AccessToken = accessToken,
RefreshToken = newRefreshToken.PlainTextToken,
ExpiresIn = 3600, // 60 minutes
TokenType = "Bearer"
});
}
catch (UnauthorizedAccessException ex)
{
_logger.LogWarning(ex, "Refresh token failed");
return Unauthorized(new { message = "Invalid or expired refresh token" });
}
}
[HttpPost("logout")]
[Authorize]
public async Task<IActionResult> Logout([FromBody] LogoutRequest request)
{
try
{
await _refreshTokenService.RevokeTokenAsync(
request.RefreshToken,
"User logout",
HttpContext.RequestAborted);
return Ok(new { message = "Logged out successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Logout failed");
return BadRequest(new { message = "Logout failed" });
}
}
[HttpPost("logout-all")]
[Authorize]
public async Task<IActionResult> LogoutAllDevices()
{
var userId = Guid.Parse(User.FindFirstValue("user_id")!);
await _refreshTokenService.RevokeAllUserTokensAsync(
userId,
"User requested logout from all devices",
HttpContext.RequestAborted);
return Ok(new { message = "Logged out from all devices" });
}
1.2.7 Security Mechanisms
1. Token Rotation Strategy:
- Each refresh token can only be used once
- Using a refresh token generates a new access token AND a new refresh token
- Old refresh token is immediately invalidated
2. Token Family Tracking:
- All rotated tokens belong to the same "family"
- If any token in a family is reused, entire family is revoked
- Detects token theft and replay attacks
3. Token Storage Security:
- Never store plain text tokens in database
- Store SHA-256 hash of tokens
- Plain text tokens only returned to client once
4. Additional Security:
- IP address and User-Agent tracking
- Last used timestamp tracking
- Automatic cleanup of expired tokens (scheduled job)
1.2.8 Configuration
appsettings.Development.json:
{
"Jwt": {
"SecretKey": "your-super-secret-key-min-32-characters-long-12345",
"Issuer": "ColaFlow.API",
"Audience": "ColaFlow.Web",
"ExpirationMinutes": "60",
"RefreshTokenExpirationDays": "7",
"RefreshTokenCleanupDays": "30"
}
}
appsettings.Production.json:
{
"Jwt": {
"SecretKey": "${JWT_SECRET_KEY}", // Environment variable
"Issuer": "ColaFlow.API",
"Audience": "ColaFlow.Web",
"ExpirationMinutes": "30", // Shorter for production
"RefreshTokenExpirationDays": "7",
"RefreshTokenCleanupDays": "30"
}
}
2. Role-Based Authorization (RBAC)
2.1 Background & Goals
Problem: Current system has no role differentiation. All authenticated users have same permissions.
Goals:
- Implement hierarchical role system
- Support tenant-level and project-level permissions
- Prepare for future MCP Server permission integration
- Enable fine-grained access control
2.2 Architecture Design
2.2.1 Role Hierarchy
Enterprise Architecture:
┌─────────────────────────────────────────────────────┐
│ System Admin │
│ (Internal ColaFlow admin - not tenant-specific) │
└─────────────────────────────────────────────────────┘
│
┌───────────────┴───────────────┐
│ │
┌───────▼──────────┐ ┌────────▼─────────┐
│ Tenant Owner │ │ Tenant Admin │
│ (Full control) │ │ (Manage users) │
└───────┬──────────┘ └────────┬─────────┘
│ │
└───────────────┬───────────────┘
│
┌───────────────┴───────────────┐
│ │
┌───────▼──────────┐ ┌────────▼─────────┐
│ Project Manager │ │ Project Member │
│ (Manage project)│ │ (View/Edit) │
└───────┬──────────┘ └────────┬─────────┘
│ │
└───────────────┬───────────────┘
│
┌───────▼────────┐
│ Project Guest │
│ (View only) │
└────────────────┘
2.2.2 Permission Model
Two-Level Permission System:
-
Tenant-Level Roles (applies to entire tenant):
- TenantOwner
- TenantAdmin
- TenantMember (default)
- TenantGuest (read-only)
-
Project-Level Roles (applies to specific projects):
- ProjectOwner
- ProjectManager
- ProjectMember
- ProjectGuest
Permission Matrix:
| Action | Tenant Owner | Tenant Admin | Tenant Member | Tenant Guest |
|---|---|---|---|---|
| Manage Tenant Settings | ✅ | ❌ | ❌ | ❌ |
| Manage Billing | ✅ | ❌ | ❌ | ❌ |
| Invite/Remove Users | ✅ | ✅ | ❌ | ❌ |
| Create Projects | ✅ | ✅ | ✅ | ❌ |
| View All Projects | ✅ | ✅ | Assigned Only | Assigned Only |
| Delete Projects | ✅ | ✅ | ❌ | ❌ |
2.2.3 Database Schema Design
-- Tenant roles (user's role within a tenant)
CREATE TABLE identity.user_tenant_roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
tenant_id UUID NOT NULL,
role VARCHAR(50) NOT NULL, -- TenantOwner, TenantAdmin, TenantMember, TenantGuest
assigned_at TIMESTAMP NOT NULL DEFAULT NOW(),
assigned_by_user_id UUID NULL,
CONSTRAINT fk_user_tenant_roles_user FOREIGN KEY (user_id)
REFERENCES identity.users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_tenant_roles_tenant FOREIGN KEY (tenant_id)
REFERENCES identity.tenants(id) ON DELETE CASCADE,
CONSTRAINT fk_user_tenant_roles_assigned_by FOREIGN KEY (assigned_by_user_id)
REFERENCES identity.users(id) ON DELETE SET NULL,
-- One role per user per tenant
CONSTRAINT uq_user_tenant_role UNIQUE (user_id, tenant_id)
);
CREATE INDEX idx_user_tenant_roles_user_id ON identity.user_tenant_roles(user_id);
CREATE INDEX idx_user_tenant_roles_tenant_id ON identity.user_tenant_roles(tenant_id);
-- Project roles (will be in Projects module, shown here for reference)
-- This table will be created when Projects module is implemented
-- CREATE TABLE projects.user_project_roles (
-- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- user_id UUID NOT NULL,
-- project_id UUID NOT NULL,
-- role VARCHAR(50) NOT NULL, -- ProjectOwner, ProjectManager, ProjectMember, ProjectGuest
-- assigned_at TIMESTAMP NOT NULL DEFAULT NOW(),
-- assigned_by_user_id UUID NULL
-- );
2.2.4 Domain Model
TenantRole Enum (Domain/Aggregates/Users/TenantRole.cs):
public enum TenantRole
{
TenantOwner = 1, // Full control
TenantAdmin = 2, // User management
TenantMember = 3, // Default role
TenantGuest = 4 // Read-only
}
UserTenantRole Entity (Domain/Aggregates/Users/UserTenantRole.cs):
public sealed class UserTenantRole : Entity
{
public UserId UserId { get; private set; } = null!;
public TenantId TenantId { get; private set; } = null!;
public TenantRole Role { get; private set; }
public DateTime AssignedAt { get; private set; }
public Guid? AssignedByUserId { get; private set; }
private UserTenantRole() : base() { }
public static UserTenantRole Create(
UserId userId,
TenantId tenantId,
TenantRole role,
Guid? assignedByUserId = null)
{
return new UserTenantRole
{
Id = Guid.NewGuid(),
UserId = userId,
TenantId = tenantId,
Role = role,
AssignedAt = DateTime.UtcNow,
AssignedByUserId = assignedByUserId
};
}
public void UpdateRole(TenantRole newRole, Guid updatedByUserId)
{
if (Role == newRole)
return;
Role = newRole;
AssignedByUserId = updatedByUserId;
// Note: AssignedAt intentionally not updated to preserve original assignment date
}
}
Update User Entity (Domain/Aggregates/Users/User.cs):
// Add to User entity
public TenantRole GetTenantRole()
{
// This will be loaded from UserTenantRole entity
// For now, return default
return TenantRole.TenantMember;
}
2.2.5 Authorization Implementation
Policy-Based Authorization (Program.cs):
// Add authorization policies
builder.Services.AddAuthorization(options =>
{
// Tenant-level policies
options.AddPolicy("RequireTenantOwner", policy =>
policy.RequireClaim("tenant_role", "TenantOwner"));
options.AddPolicy("RequireTenantAdmin", policy =>
policy.RequireAssertion(context =>
context.User.HasClaim(c => c.Type == "tenant_role" &&
(c.Value == "TenantOwner" || c.Value == "TenantAdmin"))));
options.AddPolicy("RequireTenantMember", policy =>
policy.RequireAssertion(context =>
context.User.HasClaim(c => c.Type == "tenant_role" &&
(c.Value == "TenantOwner" || c.Value == "TenantAdmin" || c.Value == "TenantMember"))));
// Future: Project-level policies
options.AddPolicy("RequireProjectOwner", policy =>
policy.RequireClaim("project_role", "ProjectOwner"));
});
Update JWT Claims (Infrastructure/Services/JwtService.cs):
public string GenerateToken(User user, Tenant tenant, TenantRole tenantRole)
{
var securityKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(_configuration["Jwt:SecretKey"] ??
throw new InvalidOperationException("JWT SecretKey not configured")));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new(JwtRegisteredClaimNames.Email, user.Email.Value),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new("user_id", user.Id.ToString()),
new("tenant_id", tenant.Id.ToString()),
new("tenant_slug", tenant.Slug.Value),
new("tenant_plan", tenant.Plan.ToString()),
new("full_name", user.FullName.Value),
new("auth_provider", user.AuthProvider.ToString()),
// NEW: Tenant-level role
new("tenant_role", tenantRole.ToString()),
new(ClaimTypes.Role, tenantRole.ToString()) // Standard claim for [Authorize(Roles = "...")]
};
var token = new JwtSecurityToken(
issuer: _configuration["Jwt:Issuer"],
audience: _configuration["Jwt:Audience"],
claims: claims,
expires: DateTime.UtcNow.AddMinutes(Convert.ToDouble(_configuration["Jwt:ExpirationMinutes"] ?? "60")),
signingCredentials: credentials
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
2.2.6 Authorization Attributes
Custom Authorization Attribute (API/Authorization/RequireTenantRoleAttribute.cs):
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
public class RequireTenantRoleAttribute : AuthorizeAttribute
{
public RequireTenantRoleAttribute(params TenantRole[] roles)
{
Roles = string.Join(",", roles.Select(r => r.ToString()));
}
}
Usage Examples:
// Controller-level authorization
[ApiController]
[Route("api/tenants")]
[RequireTenantRole(TenantRole.TenantAdmin, TenantRole.TenantOwner)]
public class TenantManagementController : ControllerBase
{
// All actions require TenantAdmin or TenantOwner
}
// Action-level authorization
[HttpDelete("{userId}")]
[RequireTenantRole(TenantRole.TenantOwner)]
public async Task<IActionResult> DeleteUser(Guid userId)
{
// Only TenantOwner can delete users
}
// Fine-grained authorization
[HttpPost("projects")]
[Authorize] // Any authenticated user
public async Task<IActionResult> CreateProject([FromBody] CreateProjectCommand command)
{
// Check role in code for complex logic
var tenantRole = User.FindFirstValue("tenant_role");
if (tenantRole == "TenantGuest")
{
return Forbid("Guests cannot create projects");
}
// Continue with project creation
}
2.2.7 Repository Pattern
IUserTenantRoleRepository (Domain/Repositories/IUserTenantRoleRepository.cs):
public interface IUserTenantRoleRepository
{
Task<UserTenantRole?> GetByUserAndTenantAsync(
Guid userId,
Guid tenantId,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<UserTenantRole>> GetByTenantAsync(
Guid tenantId,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<UserTenantRole>> GetByUserAsync(
Guid userId,
CancellationToken cancellationToken = default);
Task AddAsync(UserTenantRole role, CancellationToken cancellationToken = default);
Task UpdateAsync(UserTenantRole role, CancellationToken cancellationToken = default);
Task DeleteAsync(UserTenantRole role, CancellationToken cancellationToken = default);
}
2.2.8 Command Handlers Update
Update RegisterTenantCommandHandler to assign TenantOwner role:
public async Task<TenantDto> Handle(RegisterTenantCommand request, CancellationToken cancellationToken)
{
// ... existing validation ...
// Create tenant
var tenant = Tenant.Create(tenantName, tenantSlug, subscriptionPlan);
await _tenantRepository.AddAsync(tenant, cancellationToken);
// Create admin user
var hashedPassword = _passwordHasher.HashPassword(request.AdminPassword);
var adminUser = User.CreateLocal(
TenantId.From(tenant.Id),
email,
hashedPassword,
fullName);
await _userRepository.AddAsync(adminUser, cancellationToken);
// NEW: Assign TenantOwner role to admin
var tenantRole = UserTenantRole.Create(
UserId.From(adminUser.Id),
TenantId.From(tenant.Id),
TenantRole.TenantOwner);
await _userTenantRoleRepository.AddAsync(tenantRole, cancellationToken);
// Generate JWT with role
var token = _jwtService.GenerateToken(adminUser, tenant, TenantRole.TenantOwner);
// ... rest of handler ...
}
3. Email Verification Flow
3.1 Background & Goals
Problem: Users can register with any email without verification, leading to:
- Invalid email addresses in system
- Security risk (account takeover)
- Compliance issues (GDPR)
Goals:
- Verify email ownership during registration
- Support re-sending verification emails
- Block unverified users from critical actions
- Prepare for password reset flow
3.2 Architecture Design
3.2.1 Verification Flow Diagram
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Client │ │ API Server │ │Email Service│
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
│ 1. Register (email) │ │
├────────────────────────────────>│ │
│ │ │
│ │ 2. Generate token │
│ │ Save to DB │
│ │ │
│ │ 3. Send verification email│
│ ├───────────────────────────>│
│ │ │
│ 4. Success (please check email)│ │
│<────────────────────────────────┤ │
│ │ │
│ │ 5. Email delivered │
│ │<───────────────────────────┤
│ │ │
│ 6. Click verification link │ │
│ (GET /verify-email?token=XX)│ │
├────────────────────────────────>│ │
│ │ │
│ │ 7. Validate token │
│ │ Update EmailVerifiedAt │
│ │ │
│ 8. Email verified (redirect) │ │
│<────────────────────────────────┤ │
│ │ │
3.2.2 Token Design
Token Structure:
- Base64-encoded GUID (URL-safe)
- Expiration: 24 hours (configurable)
- One-time use only
- Stored as SHA-256 hash in database
Token Generation:
public string GenerateEmailVerificationToken()
{
var tokenBytes = new byte[32];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(tokenBytes);
return Convert.ToBase64String(tokenBytes)
.Replace("+", "-")
.Replace("/", "_")
.TrimEnd('='); // URL-safe base64
}
3.2.3 Database Schema (Already Exists)
The User entity already has email verification fields:
public DateTime? EmailVerifiedAt { get; private set; }
public string? EmailVerificationToken { get; private set; }
Add expiration field:
ALTER TABLE identity.users
ADD COLUMN email_verification_token_expires_at TIMESTAMP NULL;
Update User.cs:
public DateTime? EmailVerificationTokenExpiresAt { get; private set; }
public void SetEmailVerificationToken(string token, DateTime expiresAt)
{
EmailVerificationToken = ComputeSha256Hash(token); // Store hash
EmailVerificationTokenExpiresAt = expiresAt;
UpdatedAt = DateTime.UtcNow;
}
public bool IsEmailVerificationTokenValid(string token)
{
if (EmailVerificationToken == null || EmailVerificationTokenExpiresAt == null)
return false;
if (DateTime.UtcNow > EmailVerificationTokenExpiresAt)
return false;
var tokenHash = ComputeSha256Hash(token);
return EmailVerificationToken == tokenHash;
}
3.2.4 Email Service Design
Interface: Application/Services/IEmailService.cs
public interface IEmailService
{
Task SendEmailVerificationAsync(
string recipientEmail,
string recipientName,
string verificationToken,
CancellationToken cancellationToken = default);
Task SendPasswordResetAsync(
string recipientEmail,
string recipientName,
string resetToken,
CancellationToken cancellationToken = default);
Task SendWelcomeEmailAsync(
string recipientEmail,
string recipientName,
CancellationToken cancellationToken = default);
}
Implementation Options:
| Provider | Pros | Cons | Cost |
|---|---|---|---|
| SendGrid | Easy setup, 100 emails/day free | Rate limits | Free/Paid |
| AWS SES | Scalable, cheap (0.10/1000) | Complex setup | Pay-as-you-go |
| MailKit (SMTP) | No external dependency | Requires SMTP server | Self-hosted |
| Mailgun | Developer-friendly API | Limited free tier | Free/Paid |
Recommendation: SendGrid for MVP (easy setup, generous free tier)
Implementation: Infrastructure/Services/SendGridEmailService.cs
public class SendGridEmailService : IEmailService
{
private readonly IConfiguration _configuration;
private readonly ILogger<SendGridEmailService> _logger;
private readonly SendGridClient _client;
public SendGridEmailService(IConfiguration configuration, ILogger<SendGridEmailService> logger)
{
_configuration = configuration;
_logger = logger;
var apiKey = _configuration["SendGrid:ApiKey"];
if (string.IsNullOrEmpty(apiKey))
throw new InvalidOperationException("SendGrid API key not configured");
_client = new SendGridClient(apiKey);
}
public async Task SendEmailVerificationAsync(
string recipientEmail,
string recipientName,
string verificationToken,
CancellationToken cancellationToken)
{
var from = new EmailAddress(
_configuration["SendGrid:FromEmail"] ?? "noreply@colaflow.com",
"ColaFlow");
var to = new EmailAddress(recipientEmail, recipientName);
var verificationUrl = $"{_configuration["App:BaseUrl"]}/verify-email?token={verificationToken}";
var subject = "Verify your ColaFlow email address";
var plainTextContent = $"Please verify your email by clicking: {verificationUrl}";
var htmlContent = $@"
<h2>Welcome to ColaFlow!</h2>
<p>Please verify your email address by clicking the button below:</p>
<p><a href=""{verificationUrl}"" style=""background-color: #4CAF50; color: white; padding: 14px 20px; text-decoration: none; border-radius: 4px;"">Verify Email</a></p>
<p>Or copy and paste this link into your browser:</p>
<p>{verificationUrl}</p>
<p>This link expires in 24 hours.</p>
";
var msg = MailHelper.CreateSingleEmail(from, to, subject, plainTextContent, htmlContent);
var response = await _client.SendEmailAsync(msg, cancellationToken);
if (response.StatusCode != System.Net.HttpStatusCode.OK &&
response.StatusCode != System.Net.HttpStatusCode.Accepted)
{
_logger.LogError("Failed to send verification email to {Email}, status: {Status}",
recipientEmail, response.StatusCode);
throw new InvalidOperationException("Failed to send verification email");
}
_logger.LogInformation("Sent verification email to {Email}", recipientEmail);
}
}
3.2.5 Command Handlers
New Command: Application/Commands/VerifyEmail/VerifyEmailCommand.cs
public record VerifyEmailCommand(string Token) : IRequest<bool>;
public class VerifyEmailCommandHandler : IRequestHandler<VerifyEmailCommand, bool>
{
private readonly IUserRepository _userRepository;
private readonly ILogger<VerifyEmailCommandHandler> _logger;
public async Task<bool> Handle(VerifyEmailCommand request, CancellationToken cancellationToken)
{
// Find user by token hash
var tokenHash = ComputeSha256Hash(request.Token);
var user = await _userRepository.GetByEmailVerificationTokenAsync(tokenHash, cancellationToken);
if (user == null)
{
_logger.LogWarning("Email verification failed: token not found");
return false;
}
// Validate token
if (!user.IsEmailVerificationTokenValid(request.Token))
{
_logger.LogWarning("Email verification failed for user {UserId}: token invalid or expired", user.Id);
return false;
}
// Verify email
user.VerifyEmail();
await _userRepository.UpdateAsync(user, cancellationToken);
_logger.LogInformation("Email verified for user {UserId}", user.Id);
return true;
}
private static string ComputeSha256Hash(string input)
{
using var sha256 = SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(input);
var hash = sha256.ComputeHash(bytes);
return Convert.ToBase64String(hash);
}
}
New Command: Application/Commands/ResendVerificationEmail/ResendVerificationEmailCommand.cs
public record ResendVerificationEmailCommand(string Email, string TenantSlug) : IRequest<bool>;
public class ResendVerificationEmailCommandHandler : IRequestHandler<ResendVerificationEmailCommand, bool>
{
private readonly IUserRepository _userRepository;
private readonly ITenantRepository _tenantRepository;
private readonly IEmailService _emailService;
private readonly ILogger<ResendVerificationEmailCommandHandler> _logger;
public async Task<bool> Handle(ResendVerificationEmailCommand request, CancellationToken cancellationToken)
{
// Find user
var tenant = await _tenantRepository.GetBySlugAsync(request.TenantSlug, cancellationToken);
if (tenant == null) return false;
var user = await _userRepository.GetByEmailAsync(request.Email, tenant.Id, cancellationToken);
if (user == null) return false;
// Check if already verified
if (user.EmailVerifiedAt.HasValue)
{
_logger.LogInformation("User {UserId} already verified", user.Id);
return true; // Already verified, consider success
}
// Generate new token
var token = GenerateEmailVerificationToken();
var expiresAt = DateTime.UtcNow.AddHours(24);
user.SetEmailVerificationToken(token, expiresAt);
await _userRepository.UpdateAsync(user, cancellationToken);
// Send email
await _emailService.SendEmailVerificationAsync(
user.Email.Value,
user.FullName.Value,
token,
cancellationToken);
_logger.LogInformation("Resent verification email to user {UserId}", user.Id);
return true;
}
}
3.2.6 API Endpoints
[HttpGet("verify-email")]
[AllowAnonymous]
public async Task<IActionResult> VerifyEmail([FromQuery] string token)
{
if (string.IsNullOrEmpty(token))
return BadRequest(new { message = "Token is required" });
var command = new VerifyEmailCommand(token);
var result = await _mediator.Send(command);
if (result)
{
// Redirect to success page
return Redirect($"{_configuration["App:FrontendUrl"]}/email-verified");
}
else
{
// Redirect to error page
return Redirect($"{_configuration["App:FrontendUrl"]}/email-verification-failed");
}
}
[HttpPost("resend-verification")]
[AllowAnonymous]
public async Task<IActionResult> ResendVerification([FromBody] ResendVerificationRequest request)
{
var command = new ResendVerificationEmailCommand(request.Email, request.TenantSlug);
var result = await _mediator.Send(command);
// Always return success to prevent email enumeration
return Ok(new { message = "If the email exists, a verification link has been sent" });
}
[HttpGet("me")]
[Authorize]
public async Task<IActionResult> GetCurrentUser()
{
var userId = Guid.Parse(User.FindFirstValue("user_id")!);
var user = await _userRepository.GetByIdAsync(userId);
return Ok(new
{
userId = user.Id,
email = user.Email.Value,
fullName = user.FullName.Value,
emailVerified = user.EmailVerifiedAt.HasValue,
emailVerifiedAt = user.EmailVerifiedAt
});
}
3.2.7 Update RegisterTenant Flow
Update RegisterTenantCommandHandler.cs:
public async Task<TenantDto> Handle(RegisterTenantCommand request, CancellationToken cancellationToken)
{
// ... existing validation and creation ...
// Create admin user
var hashedPassword = _passwordHasher.HashPassword(request.AdminPassword);
var adminUser = User.CreateLocal(tenantId, email, hashedPassword, fullName);
// Generate email verification token
var verificationToken = GenerateEmailVerificationToken();
var tokenExpiresAt = DateTime.UtcNow.AddHours(24);
adminUser.SetEmailVerificationToken(verificationToken, tokenExpiresAt);
await _userRepository.AddAsync(adminUser, cancellationToken);
// Send verification email
await _emailService.SendEmailVerificationAsync(
adminUser.Email.Value,
adminUser.FullName.Value,
verificationToken,
cancellationToken);
// Generate JWT (user can login even if email not verified)
var token = _jwtService.GenerateToken(adminUser, tenant, TenantRole.TenantOwner);
_logger.LogInformation(
"Tenant {TenantId} registered, verification email sent to {Email}",
tenant.Id, adminUser.Email.Value);
// ... return response ...
}
3.2.8 Configuration
appsettings.Development.json:
{
"SendGrid": {
"ApiKey": "${SENDGRID_API_KEY}",
"FromEmail": "noreply@colaflow.com",
"FromName": "ColaFlow"
},
"App": {
"BaseUrl": "http://localhost:5167",
"FrontendUrl": "http://localhost:3000"
},
"EmailVerification": {
"TokenExpirationHours": "24",
"RequireVerification": "false"
}
}
appsettings.Production.json:
{
"SendGrid": {
"ApiKey": "${SENDGRID_API_KEY}",
"FromEmail": "noreply@colaflow.com",
"FromName": "ColaFlow"
},
"App": {
"BaseUrl": "https://api.colaflow.com",
"FrontendUrl": "https://app.colaflow.com"
},
"EmailVerification": {
"TokenExpirationHours": "24",
"RequireVerification": "true"
}
}
4. Risk Assessment
4.1 Technical Risks
| Risk | Impact | Probability | Mitigation |
|---|---|---|---|
| Refresh token database performance | Medium | Low | Add proper indexes, implement cleanup job, plan Redis migration |
| Token family revocation complexity | Medium | Medium | Thorough testing, clear logging, transaction safety |
| Email delivery failures | High | Medium | Implement retry mechanism, queue system (future), fallback SMTP |
| Role permission escalation | High | Low | Comprehensive testing, audit logging, code review |
| Migration data corruption | High | Low | Test migrations thoroughly, backup database, use transactions |
4.2 Security Risks
| Risk | Impact | Mitigation |
|---|---|---|
| Token theft | High | Token rotation, family revocation, HTTPS-only, IP tracking |
| Privilege escalation | High | Policy-based authorization, audit logs, principle of least privilege |
| Email enumeration | Medium | Generic error messages, rate limiting |
| Token replay attacks | High | One-time use tokens, token family tracking |
| Brute force token guessing | Medium | Cryptographically secure tokens (64 bytes), short expiration |
4.3 Complexity Assessment
| Feature | Complexity | Development Time | Testing Time |
|---|---|---|---|
| Refresh Token | Medium | 4-6 hours | 2-3 hours |
| RBAC | Medium-High | 6-8 hours | 3-4 hours |
| Email Verification | Low-Medium | 3-4 hours | 2 hours |
| Total | - | 13-18 hours | 7-9 hours |
Total Estimated Time: 20-27 hours (2.5-3.5 days)
5. Implementation Roadmap
5.1 Phase 1: Refresh Token (Priority 1) - Day 5 Morning
Tasks:
- Create database migration for
refresh_tokenstable - Implement
RefreshTokendomain entity - Implement
IRefreshTokenRepositoryand repository - Implement
IRefreshTokenServiceand service - Update
JwtServiceto support refresh token generation - Add
/api/auth/refresh,/api/auth/logout,/api/auth/logout-allendpoints - Update
LoginCommandHandlerto return refresh token - Test token rotation and revocation
Files to Create:
Domain/Aggregates/Users/RefreshToken.csDomain/Repositories/IRefreshTokenRepository.csInfrastructure/Persistence/Configurations/RefreshTokenConfiguration.csInfrastructure/Persistence/Repositories/RefreshTokenRepository.csApplication/Services/IRefreshTokenService.csInfrastructure/Services/RefreshTokenService.csInfrastructure/Persistence/Migrations/XXXXXX_AddRefreshTokens.cs
Files to Modify:
Application/Commands/Login/LoginCommandHandler.csAPI/Controllers/AuthController.csappsettings.Development.json
5.2 Phase 2: RBAC (Priority 1) - Day 5 Afternoon
Tasks:
- Create database migration for
user_tenant_rolestable - Implement
TenantRoleenum andUserTenantRoleentity - Implement
IUserTenantRoleRepositoryand repository - Update
JwtServiceto include role claims - Configure authorization policies in
Program.cs - Update
RegisterTenantCommandHandlerto assign TenantOwner role - Update
LoginCommandHandlerto load user role - Test role-based authorization
Files to Create:
Domain/Aggregates/Users/TenantRole.csDomain/Aggregates/Users/UserTenantRole.csDomain/Repositories/IUserTenantRoleRepository.csInfrastructure/Persistence/Configurations/UserTenantRoleConfiguration.csInfrastructure/Persistence/Repositories/UserTenantRoleRepository.csAPI/Authorization/RequireTenantRoleAttribute.csInfrastructure/Persistence/Migrations/XXXXXX_AddUserTenantRoles.cs
Files to Modify:
Infrastructure/Services/JwtService.csApplication/Commands/RegisterTenant/RegisterTenantCommandHandler.csApplication/Commands/Login/LoginCommandHandler.csAPI/Program.csAPI/Controllers/AuthController.cs(add role info to/meendpoint)
5.3 Phase 3: Email Verification (Priority 2) - Day 6 Morning (Optional)
Tasks:
- Create database migration to add
EmailVerificationTokenExpiresAtcolumn - Update
Userentity with token validation methods - Implement
IEmailServiceinterface - Implement
SendGridEmailService(or SMTP fallback) - Create
VerifyEmailCommandand handler - Create
ResendVerificationEmailCommandand handler - Update
RegisterTenantCommandHandlerto send verification email - Add
/api/auth/verify-emailand/api/auth/resend-verificationendpoints - Test email flow end-to-end
Files to Create:
Application/Services/IEmailService.csInfrastructure/Services/SendGridEmailService.csApplication/Commands/VerifyEmail/VerifyEmailCommand.csApplication/Commands/VerifyEmail/VerifyEmailCommandHandler.csApplication/Commands/ResendVerificationEmail/ResendVerificationEmailCommand.csApplication/Commands/ResendVerificationEmail/ResendVerificationEmailCommandHandler.csInfrastructure/Persistence/Migrations/XXXXXX_AddEmailVerificationExpiration.cs
Files to Modify:
Domain/Aggregates/Users/User.csApplication/Commands/RegisterTenant/RegisterTenantCommandHandler.csAPI/Controllers/AuthController.csInfrastructure/DependencyInjection.csappsettings.Development.json
5.4 Testing Strategy
Unit Tests:
RefreshTokenentity business logicUserTenantRoleentity business logicUser.VerifyEmail()and token validation methodsRefreshTokenServicetoken generation and rotation- JWT claims generation with roles
Integration Tests:
- Full refresh token flow (generate → use → rotate → revoke)
- Role-based authorization (correct roles allowed, others denied)
- Email verification flow (send → verify → check status)
- Token family revocation on suspicious activity
Security Tests:
- Token reuse detection
- Expired token rejection
- Invalid role access denial
- Email enumeration prevention
6. MCP Integration Considerations
6.1 Authentication for MCP Server
When implementing MCP Server (future), the authentication system needs to support:
-
API Key Authentication (for AI tools):
- Generate long-lived API keys per tenant
- API keys inherit user's tenant role
- Scoped permissions (read-only, write with approval)
-
OAuth 2.0 for Third-Party MCP Clients:
- Authorization code flow
- Scope-based permissions
- Refresh token support
6.2 Permission Model for MCP
MCP-specific permissions (future expansion):
public enum McpPermission
{
// Resource permissions
ReadProjects,
ReadIssues,
ReadDocuments,
// Tool permissions (with human approval)
CreateIssue,
UpdateIssueStatus,
CreateDocument,
LogDecision,
// Admin permissions
ManageIntegrations,
ViewAuditLogs
}
RBAC → MCP Permission Mapping:
| Tenant Role | MCP Read | MCP Write | MCP Admin |
|---|---|---|---|
| TenantOwner | ✅ | ✅ (with approval) | ✅ |
| TenantAdmin | ✅ | ✅ (with approval) | ✅ |
| TenantMember | ✅ | ✅ (with approval) | ❌ |
| TenantGuest | ✅ | ❌ | ❌ |
6.3 Audit Logging for MCP Operations
All MCP operations should be logged with:
- User/API key identifier
- Action performed
- Timestamp
- IP address
- Approval status (if required)
Schema (future):
CREATE TABLE audit.mcp_operations (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
tenant_id UUID NOT NULL,
operation VARCHAR(100) NOT NULL,
resource_type VARCHAR(50) NOT NULL,
resource_id UUID NULL,
approved_by_user_id UUID NULL,
approved_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL,
ip_address VARCHAR(45) NULL
);
7. Configuration Summary
7.1 Required Environment Variables
Production:
# JWT Configuration
JWT_SECRET_KEY=<64-character-random-string>
# SendGrid (Email)
SENDGRID_API_KEY=<sendgrid-api-key>
# Database
DATABASE_CONNECTION_STRING=<postgresql-connection-string>
# Application URLs
APP_BASE_URL=https://api.colaflow.com
APP_FRONTEND_URL=https://app.colaflow.com
7.2 NuGet Packages Required
<!-- Identity.Infrastructure -->
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<!-- For Email (choose one) -->
<PackageReference Include="SendGrid" Version="9.28.1" />
<!-- OR -->
<PackageReference Include="MailKit" Version="4.3.0" />
8. Success Criteria
8.1 Refresh Token
- Users can obtain refresh token on login
- Refresh token can be used to get new access token
- Refresh token rotation works correctly
- Token reuse is detected and entire family is revoked
- Users can logout from current device
- Users can logout from all devices
- Expired tokens are rejected
8.2 RBAC
- New tenants have TenantOwner role assigned
- JWT tokens contain role claims
- Role-based authorization works at endpoint level
- Different roles have different permissions
- Unauthorized access returns 403 Forbidden
- Role information visible in
/meendpoint
8.3 Email Verification
- Verification email sent on registration
- Verification link works and marks email as verified
- Expired verification links are rejected
- Users can resend verification email
- Email verification status visible in user profile
9. Performance Considerations
9.1 Database Optimization
Indexes:
- All foreign keys indexed
- Token hash columns indexed (for fast lookup)
- Composite index on (expires_at, revoked_at) for cleanup queries
Query Performance:
- Refresh token lookup: < 10ms (indexed)
- Role lookup: < 5ms (indexed)
- User verification: < 15ms (indexed)
9.2 Caching Strategy (Future)
Redis caching candidates:
- User roles (cache for 5 minutes)
- Refresh token validity (cache for token lifetime)
- Email verification status (cache for 1 hour)
10. Rollback Plan
10.1 Database Rollback
All migrations must have Down() methods:
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "refresh_tokens",
schema: "identity");
migrationBuilder.DropTable(
name: "user_tenant_roles",
schema: "identity");
migrationBuilder.DropColumn(
name: "email_verification_token_expires_at",
schema: "identity",
table: "users");
}
10.2 Feature Flags
Consider adding feature flags for gradual rollout:
{
"Features": {
"RefreshToken": true,
"RoleBasedAuthorization": true,
"EmailVerification": false
}
}
11. Documentation Requirements
API Documentation (Swagger/OpenAPI):
- Document all new endpoints
- Include request/response examples
- Document error codes
Developer Documentation:
- How to configure SendGrid
- How to test authentication flow locally
- How to add new roles
Security Documentation:
- Token rotation mechanism
- Role hierarchy
- Permission model
Conclusion
This architecture design provides a comprehensive, secure, and scalable foundation for Day 5 development. The design prioritizes:
- Security: Token rotation, hash storage, audit logging
- Scalability: PostgreSQL for MVP with clear Redis migration path
- Extensibility: RBAC system ready for MCP integration
- Maintainability: Clean architecture, clear separation of concerns
Recommended Implementation Order:
- Refresh Token (4-6 hours) - Critical for user experience
- RBAC (6-8 hours) - Foundation for all future authorization
- Email Verification (3-4 hours) - Important for security and compliance
Total Estimated Time: 20-27 hours (2.5-3.5 days of focused development)
The architecture is production-ready with appropriate configuration changes and aligns with the ColaFlow vision of secure, AI-powered project management.
Next Steps:
- Review and approve architecture design
- Set up development environment (SendGrid account, test database)
- Begin implementation starting with Refresh Token
- Execute comprehensive testing after each phase
- Update Day 5 documentation with actual implementation details
Document Version: 1.0 Last Updated: 2025-11-03 Status: Ready for Implementation