-
Notifications
You must be signed in to change notification settings - Fork 0
10 03 Building Authentication Service
This guide walks through building a SQL database-backed authentication service that implements FractalDataWorks abstractions. The patterns shown here are now provided by the core framework package FractalDataWorks.Services.Authentication.Sql.
Authentication and multi-tenancy are provided by FractalDataWorks core packages:
| Package | Purpose |
|---|---|
FractalDataWorks.Services.Authentication.Sql |
SQL-based user management and password validation |
FractalDataWorks.Services.Authentication.Jwt |
JWT token generation and validation |
FractalDataWorks.Services.Multitenancy.Abstractions |
Tenant interfaces (ITenant, ITenantContext, ITenantProvider) |
FractalDataWorks.Services.Multitenancy.Sql |
SQL Server-backed tenant provider using IDataGateway
|
These packages implement existing FDW interfaces rather than creating new abstractions. The examples below show the implementation patterns used in these core packages.
Before starting, ensure you understand:
- ReferenceSolutions Architecture - Package management
- ManagedConfiguration - Configuration patterns
- MessageLogging - Structured logging
Note: The
Reference.AuthenticationSharedPackage has been removed. Authentication is now provided by the core framework packageFractalDataWorks.Services.Authentication.Sql. The patterns below show how it was built and serve as a guide for building similar services.
cd src
dotnet new classlib -n FractalDataWorks.Services.Authentication.SqlConfigure the .csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FractalDataWorks.Abstractions" />
<PackageReference Include="FractalDataWorks.Results" />
<PackageReference Include="FractalDataWorks.Messages" />
<PackageReference Include="Microsoft.Data.SqlClient" />
<PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" />
</ItemGroup>
</Project>Create Models/User.cs:
namespace FractalDataWorks.Reference.Authentication.Models;
/// <summary>
/// Represents an authenticated user.
/// </summary>
public sealed class User
{
public int Id { get; set; }
public string Username { get; set; } = "";
public string PasswordHash { get; set; } = "";
public string? Email { get; set; }
public bool IsActive { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? LastLoginAt { get; set; }
public List<string> Roles { get; set; } = [];
}Create Hashing/IPasswordHasher.cs:
namespace FractalDataWorks.Reference.Authentication.Hashing;
/// <summary>
/// Password hashing service using PBKDF2.
/// </summary>
public interface IPasswordHasher
{
/// <summary>
/// Hashes a password with a randomly generated salt.
/// </summary>
string HashPassword(string password);
/// <summary>
/// Verifies a password against a stored hash.
/// </summary>
bool VerifyPassword(string password, string hash);
}Create Hashing/Pbkdf2PasswordHasher.cs:
using System.Security.Cryptography;
using Microsoft.AspNetCore.Cryptography.KeyDerivation;
namespace FractalDataWorks.Reference.Authentication.Hashing;
/// <summary>
/// PBKDF2-based password hasher with OWASP-recommended settings.
/// Format: {base64-salt}${base64-hash}
/// </summary>
public sealed class Pbkdf2PasswordHasher : IPasswordHasher
{
private const int SaltSize = 16; // 128 bits
private const int HashSize = 32; // 256 bits
private const int Iterations = 100_000; // OWASP minimum recommendation
public string HashPassword(string password)
{
var salt = RandomNumberGenerator.GetBytes(SaltSize);
var hash = KeyDerivation.Pbkdf2(
password: password,
salt: salt,
prf: KeyDerivationPrf.HMACSHA256,
iterationCount: Iterations,
numBytesRequested: HashSize);
return $"{Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}";
}
public bool VerifyPassword(string password, string hash)
{
var parts = hash.Split('$');
if (parts.Length != 2) return false;
var salt = Convert.FromBase64String(parts[0]);
var storedHash = Convert.FromBase64String(parts[1]);
var computedHash = KeyDerivation.Pbkdf2(
password: password,
salt: salt,
prf: KeyDerivationPrf.HMACSHA256,
iterationCount: Iterations,
numBytesRequested: HashSize);
return CryptographicOperations.FixedTimeEquals(computedHash, storedHash);
}
}Key Security Points:
- 100,000 iterations (OWASP recommendation)
- HMAC-SHA256 pseudo-random function
- Constant-time comparison to prevent timing attacks
- Unique salt per password
Create Services/IAuthenticationService.cs:
using FractalDataWorks.Results;
using FractalDataWorks.Reference.Authentication.Models;
namespace FractalDataWorks.Reference.Authentication.Services;
/// <summary>
/// SQL database-backed authentication service.
/// </summary>
public interface IAuthenticationService
{
/// <summary>
/// Validates user credentials against the database.
/// </summary>
Task<IGenericResult<User>> ValidateCredentials(
string username, string password, CancellationToken ct = default);
/// <summary>
/// Gets a user by username.
/// </summary>
Task<IGenericResult<User>> GetUser(
string username, CancellationToken ct = default);
/// <summary>
/// Gets a user by ID.
/// </summary>
Task<IGenericResult<User>> GetUserById(
int userId, CancellationToken ct = default);
/// <summary>
/// Creates a new user with the specified roles.
/// </summary>
Task<IGenericResult<int>> CreateUser(
string username, string password, string? email,
IEnumerable<string> roles, CancellationToken ct = default);
/// <summary>
/// Updates the last login timestamp.
/// </summary>
Task<IGenericResult> UpdateLastLogin(
int userId, CancellationToken ct = default);
/// <summary>
/// Gets tenant IDs assigned to a user.
/// </summary>
Task<IGenericResult<IReadOnlyList<Guid>>> GetUserTenants(
int userId, CancellationToken ct = default);
}Design Notes:
- All methods return
IGenericResult<T>(Railway-Oriented Programming) - No
Asyncsuffix on method names (FDW convention) - Cancellation token support throughout
- Separation of concerns: validation vs retrieval
Create Services/SqlAuthenticationService.cs:
using FractalDataWorks.Results;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using FractalDataWorks.Reference.Authentication.Hashing;
using FractalDataWorks.Reference.Authentication.Models;
namespace FractalDataWorks.Reference.Authentication.Services;
public sealed class SqlAuthenticationService : IAuthenticationService
{
private readonly string _connectionString;
private readonly IPasswordHasher _passwordHasher;
private readonly ILogger<SqlAuthenticationService> _logger;
public SqlAuthenticationService(
string connectionString,
IPasswordHasher passwordHasher,
ILogger<SqlAuthenticationService> logger)
{
_connectionString = connectionString;
_passwordHasher = passwordHasher;
_logger = logger;
}
public async Task<IGenericResult<User>> ValidateCredentials(
string username, string password, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(username))
return GenericResult<User>.Failure("Username is required.");
if (string.IsNullOrWhiteSpace(password))
return GenericResult<User>.Failure("Password is required.");
var userResult = await GetUser(username, ct).ConfigureAwait(false);
if (!userResult.IsSuccess)
{
_logger.LogWarning("Authentication failed for {Username}: user not found", username);
return GenericResult<User>.Failure("Invalid username or password.");
}
var user = userResult.Value!;
if (!user.IsActive)
{
_logger.LogWarning("Authentication failed for {Username}: account inactive", username);
return GenericResult<User>.Failure("Account is inactive.");
}
if (!_passwordHasher.VerifyPassword(password, user.PasswordHash))
{
_logger.LogWarning("Authentication failed for {Username}: invalid password", username);
return GenericResult<User>.Failure("Invalid username or password.");
}
_logger.LogInformation("User {Username} authenticated successfully", username);
return GenericResult<User>.Success(user);
}
public async Task<IGenericResult<User>> GetUser(
string username, CancellationToken ct = default)
{
const string sql = """
SELECT Id, Username, PasswordHash, Email, IsActive, CreatedAt, LastLoginAt
FROM auth.Users
WHERE Username = @Username
""";
try
{
await using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync(ct).ConfigureAwait(false);
await using var command = new SqlCommand(sql, connection);
command.Parameters.AddWithValue("@Username", username);
await using var reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
if (!await reader.ReadAsync(ct).ConfigureAwait(false))
return GenericResult<User>.Failure($"User '{username}' not found.");
var user = new User
{
Id = reader.GetInt32(0),
Username = reader.GetString(1),
PasswordHash = reader.GetString(2),
Email = reader.IsDBNull(3) ? null : reader.GetString(3),
IsActive = reader.GetBoolean(4),
CreatedAt = reader.GetDateTime(5),
LastLoginAt = reader.IsDBNull(6) ? null : reader.GetDateTime(6)
};
// Load roles in separate query
user.Roles = await GetUserRoles(connection, user.Id, ct).ConfigureAwait(false);
return GenericResult<User>.Success(user);
}
catch (SqlException ex)
{
_logger.LogError(ex, "Database error getting user {Username}", username);
return GenericResult<User>.Failure($"Database error: {ex.Message}");
}
}
private static async Task<List<string>> GetUserRoles(
SqlConnection connection, int userId, CancellationToken ct)
{
const string sql = "SELECT Role FROM auth.UserRoles WHERE UserId = @UserId";
await using var command = new SqlCommand(sql, connection);
command.Parameters.AddWithValue("@UserId", userId);
var roles = new List<string>();
await using var reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
roles.Add(reader.GetString(0));
return roles;
}
// Additional methods omitted for brevity - see source code
}Create Extensions/AuthenticationServiceCollectionExtensions.cs:
using Microsoft.Extensions.DependencyInjection;
using FractalDataWorks.Reference.Authentication.Hashing;
using FractalDataWorks.Reference.Authentication.Services;
namespace FractalDataWorks.Reference.Authentication.Extensions;
public static class AuthenticationServiceCollectionExtensions
{
public static IServiceCollection AddReferenceAuthentication(
this IServiceCollection services,
string connectionString)
{
services.AddSingleton<IPasswordHasher, Pbkdf2PasswordHasher>();
services.AddScoped<IAuthenticationService>(sp =>
new SqlAuthenticationService(
connectionString,
sp.GetRequiredService<IPasswordHasher>(),
sp.GetRequiredService<ILogger<SqlAuthenticationService>>()));
return services;
}
}Add to your init.sql:
-- Users table
CREATE TABLE auth.Users (
Id INT IDENTITY(1,1) PRIMARY KEY,
Username NVARCHAR(100) NOT NULL UNIQUE,
PasswordHash NVARCHAR(500) NOT NULL,
Email NVARCHAR(256) NULL,
IsActive BIT NOT NULL DEFAULT 1,
CreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
LastLoginAt DATETIME2 NULL
);
-- User roles (many-to-many)
CREATE TABLE auth.UserRoles (
Id INT IDENTITY(1,1) PRIMARY KEY,
UserId INT NOT NULL FOREIGN KEY REFERENCES auth.Users(Id),
Role NVARCHAR(50) NOT NULL,
UNIQUE(UserId, Role)
);
CREATE INDEX IX_Users_Username ON auth.Users(Username);
CREATE INDEX IX_UserRoles_UserId ON auth.UserRoles(UserId);Note: Multi-tenancy is now provided by
FractalDataWorks.Services.Multitenancy.Sql. The patterns below show how to integrate with FDW's multitenancy packages rather than building your own.
The FDW framework provides these interfaces in FractalDataWorks.Services.Multitenancy.Abstractions:
// Core tenant definition
public interface ITenant
{
Guid Id { get; }
string Name { get; }
string Slug { get; }
bool IsActive { get; }
ITenantTheme? Theme { get; }
ITenantOptions? Options { get; }
IReadOnlyList<string> AvailableRoles { get; }
}
// Request-scoped tenant context
public interface ITenantContext
{
bool HasTenant { get; }
Guid TenantId { get; }
ITenant? Tenant { get; }
}
// Mutable context for middleware
public interface IMutableTenantContext : ITenantContext
{
void SetTenant(ITenant tenant);
void ClearTenant();
}
// Tenant lookup and resolution
public interface ITenantProvider
{
Task<IGenericResult<ITenant>> GetTenant(Guid tenantId, CancellationToken ct = default);
Task<IGenericResult<ITenant>> GetTenantBySlug(string slug, CancellationToken ct = default);
Task<IGenericResult<IEnumerable<ITenant>>> GetActiveTenants(CancellationToken ct = default);
Task<IGenericResult<bool>> ValidateTenantAccess(Guid tenantId, string userId, CancellationToken ct = default);
Task<IGenericResult<ITenant>> ResolveTenant(ITenantResolutionContext context, CancellationToken ct = default);
}The FractalDataWorks.Services.Multitenancy.Sql package provides a complete SqlTenant implementation. You don't need to create your own unless you have custom requirements.
The FDW SqlTenant class implements ITenant with these properties:
// Provided by FractalDataWorks.Services.Multitenancy.Sql
public sealed class SqlTenant : ITenant
{
public Guid Id { get; }
public string Name { get; }
public string Slug { get; }
public bool IsActive { get; }
public ITenantTheme? Theme { get; }
public ITenantOptions? Options { get; }
public IReadOnlyList<string> AvailableRoles { get; }
}The FractalDataWorks.Services.Multitenancy.Sql package provides SqlTenantProvider which uses IDataGateway for data access (not raw SQL). This integrates with FDW's connection and data infrastructure:
// Provided by FractalDataWorks.Services.Multitenancy.Sql
public sealed class SqlTenantProvider : ITenantProvider
{
private readonly IDataGateway _dataGateway;
private readonly SqlTenantConfiguration _configuration;
private readonly ILogger<SqlTenantProvider> _logger;
// Uses IDataGateway for data access - integrates with FDW connections
public async Task<IGenericResult<ITenant>> GetTenant(
Guid tenantId, CancellationToken ct = default)
{
// The FDW SqlTenantProvider uses IDataGateway to query the tenant.Tenants table
// via the DataStore/Path specified in SqlTenantConfiguration
var query = Query.From(DataSets.ByName("Tenants"))
.Where(FilterExpression.Equal("Id", tenantId));
var result = await _dataGateway.Execute(query, ct);
// ... maps result to SqlTenant
}
public async Task<IGenericResult<ITenant>> ResolveTenant(
ITenantResolutionContext context, CancellationToken ct = default)
{
// Priority: Claims > Header > Route > Host
if (context.ClaimsTenantId.HasValue)
return await GetTenant(context.ClaimsTenantId.Value, ct);
if (!string.IsNullOrEmpty(context.TenantHeader))
{
if (Guid.TryParse(context.TenantHeader, out var tenantId))
return await GetTenant(tenantId, ct);
return await GetTenantBySlug(context.TenantHeader, ct);
}
// ... additional resolution strategies
}
}Key Difference from Custom Implementation: FDW's SqlTenantProvider uses IDataGateway which integrates with the DataStore infrastructure already configured in your application. You specify DataStoreName and PathName in SqlTenantConfiguration to indicate which configured DataStore and schema to use.
The framework provides TenantResolutionMiddleware in FractalDataWorks.Services.Multitenancy.Sql that resolves tenants from JWT claims or X-Tenant-Id headers. Use the framework-provided extension methods instead of creating custom middleware:
using FractalDataWorks.Services.Multitenancy.Sql.Extensions;
// Register multi-tenancy services (reads TenantProviders config section)
builder.Services.AddMultitenancy(builder.Configuration, bootstrapLogger);
// Add middleware after UseAuthentication
app.UseAuthentication();
app.UseMultitenancy();
app.UseAuthorization();The TenantResolutionMiddleware checks for tenant identity in this order:
- JWT
tenant_idclaim -
X-Tenant-Idheader (GUID or slug)
Structured logging is provided by TenantMiddlewareLog (EventIds 560-565).
Create Middleware/TenantSessionContextMiddleware.cs:
public class TenantSessionContextMiddleware
{
private readonly RequestDelegate _next;
public async Task InvokeAsync(
HttpContext context,
ITenantContext tenantContext,
IDbConnection connection)
{
if (tenantContext.HasTenant)
{
var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
await using var cmd = new SqlCommand("""
EXEC sp_set_session_context @key=N'TenantId', @value=@TenantId;
EXEC sp_set_session_context @key=N'UserId', @value=@UserId;
""", (SqlConnection)connection);
cmd.Parameters.AddWithValue("@TenantId", tenantContext.TenantId);
cmd.Parameters.AddWithValue("@UserId", (object?)userId ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync();
}
await _next(context);
}
}-- Security predicate using SESSION_CONTEXT
CREATE FUNCTION cfg.fn_TenantSecurityPredicate(@TenantId UNIQUEIDENTIFIER)
RETURNS TABLE
WITH SCHEMABINDING
AS
RETURN SELECT 1 AS result
WHERE @TenantId = CAST(SESSION_CONTEXT(N'TenantId') AS UNIQUEIDENTIFIER)
OR SESSION_CONTEXT(N'TenantId') IS NULL -- Admin access
OR @TenantId IN (
SELECT ut.TenantId FROM tenant.UserTenants ut
WHERE ut.UserId = CAST(SESSION_CONTEXT(N'UserId') AS INT)
);
-- Apply to data tables
CREATE SECURITY POLICY cfg.TeamsSecurityPolicy
ADD FILTER PREDICATE cfg.fn_TenantSecurityPredicate(TenantId) ON nfl.Teams
WITH (STATE = ON);using FractalDataWorks.Services.Multitenancy.Abstractions;
using FractalDataWorks.Services.Multitenancy.Sql;
// Add authentication services
builder.Services.AddReferenceAuthentication(connectionString);
// Add FDW multitenancy via framework extension methods
builder.Services.AddMultitenancy(builder.Configuration, bootstrapLogger);
// ...
var app = builder.Build();
// Add middleware after UseAuthentication
app.UseAuthentication();
app.UseMultitenancy();
app.UseAuthorization();public sealed class TokenEndpoint : Endpoint<TokenRequest, TokenResponse>
{
private readonly IAuthenticationService _authService;
public override async Task HandleAsync(TokenRequest req, CancellationToken ct)
{
var authResult = await _authService.ValidateCredentials(
req.Username, req.Password, ct);
if (!authResult.IsSuccess)
{
await Send.UnauthorizedAsync(ct);
return;
}
var user = authResult.Value!;
// Get user's tenant assignments
var tenantsResult = await _authService.GetUserTenants(user.Id, ct);
Guid? tenantId = tenantsResult.IsSuccess && tenantsResult.Value?.Count == 1
? tenantsResult.Value[0]
: null;
// Generate JWT with tenant_id claim
var claims = new List<Claim>
{
new(ClaimTypes.Name, user.Username),
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
new(ClaimTypes.Role, user.Roles.FirstOrDefault() ?? "User")
};
if (tenantId.HasValue)
claims.Add(new Claim("tenant_id", tenantId.Value.ToString()));
// Create and return JWT...
}
}Preferred Approach: For new implementations, use
FractalDataWorks.Services.Authentication.Jwtwhich provides a complete JWT authentication service that usesIDataGatewayfor user lookup. This integrates with the three-phase registration pattern.
The FDW JWT authentication provider uses:
-
IFdwServiceProvider<IAuthenticationService, IAuthenticationConfiguration>for service resolution -
IDataGatewayfor database queries (via DataContainers, not raw SQL) - Database-backed configuration via
cfg.Authenticationandcfg.JwtAuthenticationtables
Create 10-seed-authentication.sql:
-- Parent: cfg.Authentication
DECLARE @AuthId UNIQUEIDENTIFIER = NEWID();
IF NOT EXISTS (SELECT 1 FROM cfg.Authentication WHERE Name = 'ApiJwtAuth' AND IsCurrent = 1)
BEGIN
INSERT INTO cfg.Authentication (Id, Name, ServiceOptionType, Description)
VALUES (@AuthId, 'ApiJwtAuth', 'Jwt', 'JWT authentication for API');
-- Child: cfg.JwtAuthentication
INSERT INTO cfg.JwtAuthentication (
AuthenticationId,
Issuer,
Audience,
SecretManagerName,
SecretKeyName,
UserStoreDataStoreName,
UserStorePathName,
UsersTableName,
UserRolesTableName
) VALUES (
@AuthId,
'FractalDataWorks.ApiSolution',
'FractalDataWorks.ApiSolution',
'EnvSecrets',
'JWT_SECRET_KEY',
'AuthDb', -- DataStore name for user store
'auth', -- Path (schema) for user store
'Users', -- DataContainer name (not table name)
'UserRoles' -- DataContainer name (not table name)
);
ENDThe JWT service uses IDataGateway to query users. Add to 09-seed-datastores.sql:
-- Users DataContainer
DECLARE @CfgPathId UNIQUEIDENTIFIER;
SELECT @CfgPathId = Id FROM cfg.DataPath WHERE Name = 'cfg';
INSERT INTO cfg.DataContainer (Id, DataPathId, Name, ContainerType, Format)
VALUES (NEWID(), @CfgPathId, 'Users', 'Table', 'Tabular');
-- Define fields (must match cfg.Users table columns)
INSERT INTO cfg.DataContainerField (Id, DataContainerId, Name, DataType, Role, IsNullable, IsPrimaryKey, Ordinal)
VALUES
(NEWID(), @ContainerId, 'Id', 'uniqueidentifier', 'Attribute', 0, 1, 0),
(NEWID(), @ContainerId, 'Username', 'nvarchar', 'Attribute', 0, 0, 1),
(NEWID(), @ContainerId, 'PasswordHash', 'nvarchar', 'Attribute', 0, 0, 2),
(NEWID(), @ContainerId, 'Email', 'nvarchar', 'Attribute', 1, 0, 3),
(NEWID(), @ContainerId, 'IsActive', 'bit', 'Attribute', 0, 0, 4);
-- Similarly for UserRoles DataContainer...using FractalDataWorks.ServiceTypes;
using FractalDataWorks.Services.Authentication.Abstractions;
using FractalDataWorks.Services.Authentication.Jwt.Services;
public sealed class TokenEndpoint : Endpoint<TokenRequest, TokenResponse>
{
private readonly IFdwServiceProvider<IAuthenticationService, IAuthenticationConfiguration> _authProvider;
private readonly ILogger<TokenEndpoint> _logger;
public TokenEndpoint(
IFdwServiceProvider<IAuthenticationService, IAuthenticationConfiguration> authProvider,
ILogger<TokenEndpoint> logger)
{
_authProvider = authProvider;
_logger = logger;
}
public override async Task HandleAsync(TokenRequest req, CancellationToken ct)
{
// Get service by configuration name (from cfg.Authentication.Name)
var serviceResult = _authProvider.Get("ApiJwtAuth");
if (!serviceResult.IsSuccess)
{
_logger.LogError("JWT service not available: {Error}", serviceResult.CurrentMessage);
HttpContext.Response.StatusCode = 500;
return;
}
if (serviceResult.Value is not IJwtAuthenticationService jwtService)
{
_logger.LogError("Service is not IJwtAuthenticationService");
HttpContext.Response.StatusCode = 500;
return;
}
// Login using the service's DataGateway-backed user lookup
var loginResult = await jwtService.Login(req.Username, req.Password, ct);
if (!loginResult.IsSuccess)
{
await Send.UnauthorizedAsync(ct);
return;
}
var tokenPair = loginResult.Value!;
await Send.OkAsync(new TokenResponse
{
AccessToken = tokenPair.AccessToken,
RefreshToken = tokenPair.RefreshToken,
TokenType = "Bearer",
ExpiresIn = (int)(tokenPair.AccessTokenExpiresAt - DateTime.UtcNow).TotalSeconds
}, ct);
}
}// Three-phase registration (with other service types)
AuthenticationTypes.Configure(builder.Services, builder.Configuration, loggerFactory);
AuthenticationTypes.Register(builder.Services, loggerFactory);
var app = builder.Build();
AuthenticationTypes.Initialize(app.Services, loggerFactory);| Aspect | Reference.Authentication | FDW JWT Provider |
|---|---|---|
| Data Access | Raw SQL (SqlConnection) |
IDataGateway + DataContainers |
| Configuration | appsettings.json |
Database (cfg.JwtAuthentication) |
| Service Resolution | Direct DI | IFdwServiceProvider.Get(name) |
| Password Hashing | Custom IPasswordHasher
|
IPasswordHasher<UserEntity> |
| Token Generation | Endpoint responsibility | IJwtAuthenticationService.Login() |
- Consistent patterns - Uses same DataGateway as all other FDW services
- Database-backed config - Change settings without redeploy
- Provider pattern - Multiple auth configurations possible (dev, prod, tenant-specific)
- Integrated token management - Refresh, revocation, and validation built-in
| Username | Password | Role | Tenant | Expected Behavior |
|---|---|---|---|---|
| testuser | TestPassword123! | User | - | Access without tenant |
| admin | AdminPassword123! | Admin | - | Cross-tenant access |
| afcuser | AfcPassword123! | User | AFC | Only sees AFC teams |
| nfcuser | NfcPassword123! | User | NFC | Only sees NFC teams |
-
Login as afcuser - Token contains
tenant_idfor AFC - GET /api/v1/nfl/teams - Only AFC teams returned (RLS filter)
-
Login as admin - No
tenant_idin token - GET /api/v1/nfl/teams - All teams returned (no RLS filter)
-
Implement existing abstractions - FDW provides
ITenant,ITenantProvider,ITenantContext -
Results over exceptions - Always return
IGenericResult<T> - RLS for data isolation - SQL Server SESSION_CONTEXT + security policies
- Middleware order matters - Resolution before authorization
- No Async suffix - FDW convention omits the suffix