Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chapter 17: Security and Authentication

Multi-Mode Authentication for POS and Admin Portals

This chapter provides complete security implementation including dual authentication flows, JWT tokens, role-based access control, and tenant isolation.


17.1 Authentication Architecture Overview

┌───────────────────────────────────────────────────────────────────┐
│                     Authentication Flows                          │
├───────────────────────────────────────────────────────────────────┤
│                                                                   │
│  ┌─────────────────┐              ┌─────────────────────────┐    │
│  │   POS Client    │              │     Admin Portal        │    │
│  │  (Touch Screen) │              │   (Web Browser)         │    │
│  └────────┬────────┘              └───────────┬─────────────┘    │
│           │                                   │                   │
│           ▼                                   ▼                   │
│  ┌─────────────────┐              ┌─────────────────────────┐    │
│  │   PIN Login     │              │  Email/Password Login   │    │
│  │  (4-6 digits)   │              │  + Optional MFA         │    │
│  └────────┬────────┘              └───────────┬─────────────┘    │
│           │                                   │                   │
│           ▼                                   ▼                   │
│  ┌─────────────────────────────────────────────────────────────┐ │
│  │                    JWT Token Issued                          │ │
│  │    • Short-lived for POS (8 hours = shift)                  │ │
│  │    • Longer for Admin (24 hours with refresh)               │ │
│  └─────────────────────────────────────────────────────────────┘ │
│                                                                   │
└───────────────────────────────────────────────────────────────────┘

17.2 JWT Token Structure

{
  "header": {
    "alg": "RS256",
    "typ": "JWT",
    "kid": "key-2025-01"
  },
  "payload": {
    "sub": "emp_john_smith",
    "tid": "tenant_nexus",
    "lid": "loc_gm",
    "rid": "reg_gm_01",
    "name": "John Smith",
    "email": "john@nexus.com",
    "roles": ["cashier", "supervisor"],
    "permissions": [
      "pos.sale.create",
      "pos.sale.void",
      "pos.discount.apply",
      "pos.customer.view",
      "pos.customer.create"
    ],
    "auth_method": "pin",
    "iat": 1705320000,
    "exp": 1705348800,
    "iss": "https://auth.pos-platform.com",
    "aud": "pos-api"
  }
}

Token Claims Explained

ClaimDescription
subSubject (employee/user ID)
tidTenant ID
lidLocation ID (POS only)
ridRegister ID (POS only)
rolesRole names
permissionsFine-grained permissions
auth_method“pin” or “password”

17.3 Role-Based Permission Matrix

Role Definitions

// File: src/POS.Domain/Security/Roles.cs
namespace POS.Domain.Security;

public static class Roles
{
    public const string Cashier = "cashier";
    public const string Supervisor = "supervisor";
    public const string Manager = "manager";
    public const string Admin = "admin";
    public const string Owner = "owner";
}

Permission Catalog

// File: src/POS.Domain/Security/Permissions.cs
namespace POS.Domain.Security;

public static class Permissions
{
    // POS Operations
    public const string PosSaleCreate = "pos.sale.create";
    public const string PosSaleVoid = "pos.sale.void";
    public const string PosSaleReturn = "pos.sale.return";
    public const string PosDiscountApply = "pos.discount.apply";
    public const string PosDiscountOverride = "pos.discount.override";
    public const string PosPriceOverride = "pos.price.override";
    public const string PosDrawerOpen = "pos.drawer.open";
    public const string PosDrawerCount = "pos.drawer.count";
    public const string PosHoldRecall = "pos.hold.recall";

    // Customer Operations
    public const string CustomerView = "pos.customer.view";
    public const string CustomerCreate = "pos.customer.create";
    public const string CustomerUpdate = "pos.customer.update";
    public const string CustomerDelete = "pos.customer.delete";
    public const string CustomerLoyaltyAdjust = "pos.customer.loyalty.adjust";

    // Inventory Operations
    public const string InventoryView = "inventory.view";
    public const string InventoryAdjust = "inventory.adjust";
    public const string InventoryTransfer = "inventory.transfer";
    public const string InventoryCount = "inventory.count";
    public const string InventoryReceive = "inventory.receive";

    // Catalog Operations
    public const string CatalogItemView = "catalog.items.read";
    public const string CatalogItemCreate = "catalog.items.write";
    public const string CatalogItemUpdate = "catalog.items.write";
    public const string CatalogItemDelete = "catalog.items.delete";
    public const string CatalogItemBulk = "catalog.items.bulk";

    // Reports
    public const string ReportsView = "reports.view";
    public const string ReportsExport = "reports.export";
    public const string ReportsSalesDetail = "reports.sales.detail";
    public const string ReportsEmployeePerformance = "reports.employee.performance";

    // Administration
    public const string AdminEmployees = "admin.employees";
    public const string AdminLocations = "admin.locations";
    public const string AdminSettings = "admin.settings";
    public const string AdminIntegrations = "admin.integrations";
    public const string AdminBilling = "admin.billing";
    public const string AdminAuditLog = "admin.audit";
}

Role-Permission Mapping

PermissionCashierSupervisorManagerAdminOwner
pos.sale.createXXXXX
pos.sale.void-XXXX
pos.sale.return-XXXX
pos.discount.apply-XXXX
pos.discount.override--XXX
pos.price.override--XXX
pos.drawer.openXXXXX
pos.drawer.count-XXXX
pos.customer.viewXXXXX
pos.customer.createXXXXX
pos.customer.update-XXXX
pos.customer.delete--XXX
inventory.viewXXXXX
inventory.adjust--XXX
inventory.transfer--XXX
inventory.count-XXXX
catalog.items.readXXXXX
catalog.items.write--XXX
catalog.items.delete---XX
reports.view-XXXX
reports.export--XXX
admin.employees--XXX
admin.locations---XX
admin.settings---XX
admin.billing----X

17.4 Authentication Controller

// File: src/POS.Api/Controllers/AuthController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;

namespace POS.Api.Controllers;

[ApiController]
[Route("api/v1/auth")]
public class AuthController : ControllerBase
{
    private readonly IEmployeeService _employeeService;
    private readonly IUserService _userService;
    private readonly ITenantService _tenantService;
    private readonly ITokenService _tokenService;
    private readonly IAuditLogger _auditLogger;
    private readonly ILogger<AuthController> _logger;

    public AuthController(
        IEmployeeService employeeService,
        IUserService userService,
        ITenantService tenantService,
        ITokenService tokenService,
        IAuditLogger auditLogger,
        ILogger<AuthController> logger)
    {
        _employeeService = employeeService;
        _userService = userService;
        _tenantService = tenantService;
        _tokenService = tokenService;
        _auditLogger = auditLogger;
        _logger = logger;
    }

    /// <summary>
    /// PIN-based login for POS terminals
    /// </summary>
    [HttpPost("pin-login")]
    [AllowAnonymous]
    public async Task<ActionResult<LoginResponse>> PinLogin(
        [FromBody] PinLoginRequest request,
        CancellationToken ct)
    {
        // Validate tenant
        var tenant = await _tenantService.GetBySubdomainAsync(
            request.TenantSubdomain, ct);

        if (tenant is null || !tenant.IsActive)
        {
            _logger.LogWarning(
                "PIN login attempt for unknown tenant: {Tenant}",
                request.TenantSubdomain);
            return Unauthorized(new ProblemDetails
            {
                Title = "Invalid Credentials",
                Detail = "The provided credentials are invalid."
            });
        }

        // Validate location
        var location = await _tenantService.GetLocationAsync(
            tenant.Id, request.LocationId, ct);

        if (location is null || !location.IsActive)
        {
            return Unauthorized(new ProblemDetails
            {
                Title = "Invalid Location",
                Detail = "The specified location is not available."
            });
        }

        // Validate employee PIN
        var employee = await _employeeService.ValidatePinAsync(
            tenant.Id, request.Pin, ct);

        if (employee is null)
        {
            await _auditLogger.LogAsync(new AuditEvent
            {
                TenantId = tenant.Id,
                EventType = "AuthFailure",
                Details = $"Failed PIN login attempt at {request.LocationId}",
                IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
            }, ct);

            return Unauthorized(new ProblemDetails
            {
                Title = "Invalid Credentials",
                Detail = "The provided PIN is incorrect."
            });
        }

        // Check employee has access to this location
        if (!employee.LocationIds.Contains(request.LocationId) &&
            !employee.Roles.Contains(Roles.Admin))
        {
            return Unauthorized(new ProblemDetails
            {
                Title = "Location Access Denied",
                Detail = "You do not have access to this location."
            });
        }

        // Generate token (8-hour shift duration)
        var token = await _tokenService.GenerateTokenAsync(new TokenRequest
        {
            Subject = employee.Id,
            TenantId = tenant.Id,
            LocationId = request.LocationId,
            RegisterId = request.RegisterId,
            Name = employee.FullName,
            Email = employee.Email,
            Roles = employee.Roles,
            AuthMethod = "pin",
            ExpiresIn = TimeSpan.FromHours(8)
        });

        await _auditLogger.LogAsync(new AuditEvent
        {
            TenantId = tenant.Id,
            EmployeeId = employee.Id,
            EventType = "PinLogin",
            Details = $"Logged in at {request.LocationId}, register {request.RegisterId}",
            IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
        }, ct);

        _logger.LogInformation(
            "Employee {EmployeeId} logged in at {LocationId}",
            employee.Id, request.LocationId);

        return Ok(new LoginResponse
        {
            Token = token.AccessToken,
            ExpiresAt = token.ExpiresAt,
            Employee = new EmployeeInfo
            {
                Id = employee.Id,
                Name = employee.FullName,
                Roles = employee.Roles,
                Permissions = employee.Permissions
            }
        });
    }

    /// <summary>
    /// Email/password login for Admin portal
    /// </summary>
    [HttpPost("login")]
    [AllowAnonymous]
    public async Task<ActionResult<LoginResponse>> Login(
        [FromBody] LoginRequest request,
        CancellationToken ct)
    {
        // Validate tenant
        var tenant = await _tenantService.GetBySubdomainAsync(
            request.TenantSubdomain, ct);

        if (tenant is null || !tenant.IsActive)
        {
            return Unauthorized(new ProblemDetails
            {
                Title = "Invalid Credentials",
                Detail = "The provided credentials are invalid."
            });
        }

        // Validate user credentials
        var user = await _userService.ValidateCredentialsAsync(
            tenant.Id, request.Email, request.Password, ct);

        if (user is null)
        {
            await _auditLogger.LogAsync(new AuditEvent
            {
                TenantId = tenant.Id,
                EventType = "AuthFailure",
                Details = $"Failed login attempt for {request.Email}",
                IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
            }, ct);

            return Unauthorized(new ProblemDetails
            {
                Title = "Invalid Credentials",
                Detail = "The provided credentials are invalid."
            });
        }

        // Check if MFA is required
        if (user.MfaEnabled)
        {
            if (string.IsNullOrEmpty(request.MfaCode))
            {
                return Ok(new LoginResponse
                {
                    RequiresMfa = true,
                    MfaToken = await _tokenService.GenerateMfaTokenAsync(user.Id)
                });
            }

            var mfaValid = await _userService.ValidateMfaCodeAsync(
                user.Id, request.MfaCode, ct);

            if (!mfaValid)
            {
                return Unauthorized(new ProblemDetails
                {
                    Title = "Invalid MFA Code",
                    Detail = "The provided MFA code is incorrect."
                });
            }
        }

        // Generate tokens (24-hour access, 7-day refresh)
        var token = await _tokenService.GenerateTokenAsync(new TokenRequest
        {
            Subject = user.Id,
            TenantId = tenant.Id,
            Name = user.FullName,
            Email = user.Email,
            Roles = user.Roles,
            AuthMethod = "password",
            ExpiresIn = TimeSpan.FromHours(24),
            IncludeRefreshToken = true
        });

        await _auditLogger.LogAsync(new AuditEvent
        {
            TenantId = tenant.Id,
            UserId = user.Id,
            EventType = "Login",
            Details = "Admin portal login",
            IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
        }, ct);

        return Ok(new LoginResponse
        {
            Token = token.AccessToken,
            RefreshToken = token.RefreshToken,
            ExpiresAt = token.ExpiresAt,
            User = new UserInfo
            {
                Id = user.Id,
                Name = user.FullName,
                Email = user.Email,
                Roles = user.Roles,
                Permissions = user.Permissions
            }
        });
    }

    /// <summary>
    /// Refresh access token using refresh token
    /// </summary>
    [HttpPost("refresh")]
    [AllowAnonymous]
    public async Task<ActionResult<LoginResponse>> RefreshToken(
        [FromBody] RefreshTokenRequest request,
        CancellationToken ct)
    {
        var result = await _tokenService.RefreshTokenAsync(
            request.RefreshToken, ct);

        if (!result.IsSuccess)
        {
            return Unauthorized(new ProblemDetails
            {
                Title = "Invalid Token",
                Detail = "The refresh token is invalid or expired."
            });
        }

        return Ok(new LoginResponse
        {
            Token = result.Value!.AccessToken,
            RefreshToken = result.Value.RefreshToken,
            ExpiresAt = result.Value.ExpiresAt
        });
    }

    /// <summary>
    /// Logout and invalidate tokens
    /// </summary>
    [HttpPost("logout")]
    [Authorize]
    public async Task<IActionResult> Logout(CancellationToken ct)
    {
        var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
        var tenantId = User.FindFirstValue("tid");

        await _tokenService.RevokeAllTokensAsync(userId!, ct);

        await _auditLogger.LogAsync(new AuditEvent
        {
            TenantId = tenantId!,
            UserId = userId,
            EventType = "Logout",
            IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
        }, ct);

        return NoContent();
    }

    /// <summary>
    /// Change PIN (for POS employees)
    /// </summary>
    [HttpPost("change-pin")]
    [Authorize]
    public async Task<IActionResult> ChangePin(
        [FromBody] ChangePinRequest request,
        CancellationToken ct)
    {
        var employeeId = User.FindFirstValue(ClaimTypes.NameIdentifier);
        var tenantId = User.FindFirstValue("tid");

        var result = await _employeeService.ChangePinAsync(
            tenantId!, employeeId!, request.CurrentPin, request.NewPin, ct);

        if (!result.IsSuccess)
        {
            return BadRequest(new ProblemDetails
            {
                Title = "PIN Change Failed",
                Detail = result.Error!.Message
            });
        }

        return NoContent();
    }

    /// <summary>
    /// Validate current session
    /// </summary>
    [HttpGet("me")]
    [Authorize]
    public async Task<ActionResult<SessionInfo>> GetCurrentSession(
        CancellationToken ct)
    {
        var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
        var tenantId = User.FindFirstValue("tid");
        var authMethod = User.FindFirstValue("auth_method");

        if (authMethod == "pin")
        {
            var employee = await _employeeService.GetByIdAsync(
                tenantId!, userId!, ct);

            return Ok(new SessionInfo
            {
                UserId = userId!,
                TenantId = tenantId!,
                Name = employee!.FullName,
                Roles = User.FindAll(ClaimTypes.Role).Select(c => c.Value).ToList(),
                Permissions = User.FindAll("permission").Select(c => c.Value).ToList(),
                LocationId = User.FindFirstValue("lid"),
                RegisterId = User.FindFirstValue("rid"),
                AuthMethod = authMethod
            });
        }
        else
        {
            var user = await _userService.GetByIdAsync(tenantId!, userId!, ct);

            return Ok(new SessionInfo
            {
                UserId = userId!,
                TenantId = tenantId!,
                Name = user!.FullName,
                Email = user.Email,
                Roles = User.FindAll(ClaimTypes.Role).Select(c => c.Value).ToList(),
                Permissions = User.FindAll("permission").Select(c => c.Value).ToList(),
                AuthMethod = authMethod!
            });
        }
    }
}

17.5 Token Service Implementation

// File: src/POS.Infrastructure/Security/TokenService.cs
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;

namespace POS.Infrastructure.Security;

public class TokenService : ITokenService
{
    private readonly JwtSettings _jwtSettings;
    private readonly IRefreshTokenRepository _refreshTokenRepo;
    private readonly IRolePermissionResolver _permissionResolver;
    private readonly ILogger<TokenService> _logger;

    public TokenService(
        IOptions<JwtSettings> jwtSettings,
        IRefreshTokenRepository refreshTokenRepo,
        IRolePermissionResolver permissionResolver,
        ILogger<TokenService> logger)
    {
        _jwtSettings = jwtSettings.Value;
        _refreshTokenRepo = refreshTokenRepo;
        _permissionResolver = permissionResolver;
        _logger = logger;
    }

    public async Task<TokenResult> GenerateTokenAsync(TokenRequest request)
    {
        var permissions = await _permissionResolver.ResolvePermissionsAsync(
            request.Roles);

        var claims = new List<Claim>
        {
            new(JwtRegisteredClaimNames.Sub, request.Subject),
            new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
            new("tid", request.TenantId),
            new("name", request.Name),
            new("auth_method", request.AuthMethod)
        };

        if (!string.IsNullOrEmpty(request.Email))
            claims.Add(new Claim(JwtRegisteredClaimNames.Email, request.Email));

        if (!string.IsNullOrEmpty(request.LocationId))
            claims.Add(new Claim("lid", request.LocationId));

        if (!string.IsNullOrEmpty(request.RegisterId))
            claims.Add(new Claim("rid", request.RegisterId));

        foreach (var role in request.Roles)
            claims.Add(new Claim(ClaimTypes.Role, role));

        foreach (var permission in permissions)
            claims.Add(new Claim("permission", permission));

        var key = new SymmetricSecurityKey(
            Convert.FromBase64String(_jwtSettings.SecretKey));

        var credentials = new SigningCredentials(
            key, SecurityAlgorithms.HmacSha256);

        var expires = DateTime.UtcNow.Add(request.ExpiresIn);

        var token = new JwtSecurityToken(
            issuer: _jwtSettings.Issuer,
            audience: _jwtSettings.Audience,
            claims: claims,
            expires: expires,
            signingCredentials: credentials
        );

        var accessToken = new JwtSecurityTokenHandler().WriteToken(token);

        var result = new TokenResult
        {
            AccessToken = accessToken,
            ExpiresAt = expires
        };

        if (request.IncludeRefreshToken)
        {
            var refreshToken = GenerateRefreshToken();
            await _refreshTokenRepo.StoreAsync(new RefreshTokenEntity
            {
                Token = refreshToken,
                UserId = request.Subject,
                TenantId = request.TenantId,
                ExpiresAt = DateTime.UtcNow.AddDays(7),
                CreatedAt = DateTime.UtcNow
            });

            result.RefreshToken = refreshToken;
        }

        return result;
    }

    public async Task<Result<TokenResult>> RefreshTokenAsync(
        string refreshToken,
        CancellationToken ct = default)
    {
        var stored = await _refreshTokenRepo.GetByTokenAsync(refreshToken, ct);

        if (stored is null || stored.IsRevoked || stored.ExpiresAt < DateTime.UtcNow)
        {
            return Result<TokenResult>.Failure(
                DomainError.InvalidToken("Refresh token is invalid or expired"));
        }

        // Revoke old refresh token
        await _refreshTokenRepo.RevokeAsync(refreshToken, ct);

        // Generate new tokens
        var newToken = await GenerateTokenAsync(new TokenRequest
        {
            Subject = stored.UserId,
            TenantId = stored.TenantId,
            Name = stored.UserName,
            Email = stored.Email,
            Roles = stored.Roles,
            AuthMethod = "password",
            ExpiresIn = TimeSpan.FromHours(24),
            IncludeRefreshToken = true
        });

        return Result<TokenResult>.Success(newToken);
    }

    public async Task RevokeAllTokensAsync(string userId, CancellationToken ct = default)
    {
        await _refreshTokenRepo.RevokeAllForUserAsync(userId, ct);
    }

    private static string GenerateRefreshToken()
    {
        var randomBytes = new byte[64];
        using var rng = RandomNumberGenerator.Create();
        rng.GetBytes(randomBytes);
        return Convert.ToBase64String(randomBytes);
    }
}

17.6 Tenant Context Middleware

// File: src/POS.Api/Middleware/TenantContextMiddleware.cs
using System.Security.Claims;

namespace POS.Api.Middleware;

public class TenantContextMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<TenantContextMiddleware> _logger;

    public TenantContextMiddleware(
        RequestDelegate next,
        ILogger<TenantContextMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(
        HttpContext context,
        ITenantContext tenantContext,
        ITenantService tenantService)
    {
        string? tenantId = null;

        // 1. Try from JWT claims (authenticated requests)
        if (context.User.Identity?.IsAuthenticated == true)
        {
            tenantId = context.User.FindFirstValue("tid");
        }

        // 2. Try from subdomain
        if (string.IsNullOrEmpty(tenantId))
        {
            var host = context.Request.Host.Host;
            var subdomain = GetSubdomain(host);

            if (!string.IsNullOrEmpty(subdomain))
            {
                var tenant = await tenantService.GetBySubdomainAsync(
                    subdomain, context.RequestAborted);

                tenantId = tenant?.Id;
            }
        }

        // 3. Try from header (API integrations)
        if (string.IsNullOrEmpty(tenantId))
        {
            tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault();
        }

        if (!string.IsNullOrEmpty(tenantId))
        {
            tenantContext.SetTenant(tenantId);

            // Add to response headers for debugging
            context.Response.Headers["X-Tenant-Id"] = tenantId;
        }
        else if (!IsPublicEndpoint(context.Request.Path))
        {
            _logger.LogWarning(
                "Unable to resolve tenant for path {Path}",
                context.Request.Path);

            context.Response.StatusCode = 400;
            await context.Response.WriteAsJsonAsync(new ProblemDetails
            {
                Title = "Tenant Required",
                Detail = "Unable to determine tenant context.",
                Status = 400
            });
            return;
        }

        await _next(context);
    }

    private static string? GetSubdomain(string host)
    {
        var parts = host.Split('.');
        if (parts.Length >= 3 && parts[0] != "www" && parts[0] != "api")
        {
            return parts[0];
        }
        return null;
    }

    private static bool IsPublicEndpoint(PathString path)
    {
        var publicPaths = new[]
        {
            "/health",
            "/api/v1/auth/login",
            "/api/v1/auth/pin-login",
            "/swagger"
        };

        return publicPaths.Any(p =>
            path.StartsWithSegments(p, StringComparison.OrdinalIgnoreCase));
    }
}

// Tenant Context Interface and Implementation
public interface ITenantContext
{
    string? TenantId { get; }
    void SetTenant(string tenantId);
}

public class TenantContext : ITenantContext
{
    public string? TenantId { get; private set; }

    public void SetTenant(string tenantId)
    {
        TenantId = tenantId;
    }
}

17.7 API Key Authentication for Integrations

// File: src/POS.Api/Authentication/ApiKeyAuthenticationHandler.cs
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using System.Security.Claims;
using System.Text.Encodings.Web;

namespace POS.Api.Authentication;

public class ApiKeyAuthenticationHandler : AuthenticationHandler<ApiKeyAuthenticationOptions>
{
    private const string ApiKeyHeaderName = "X-API-Key";
    private readonly IApiKeyService _apiKeyService;

    public ApiKeyAuthenticationHandler(
        IOptionsMonitor<ApiKeyAuthenticationOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder,
        IApiKeyService apiKeyService)
        : base(options, logger, encoder)
    {
        _apiKeyService = apiKeyService;
    }

    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        if (!Request.Headers.TryGetValue(ApiKeyHeaderName, out var apiKeyHeaderValues))
        {
            return AuthenticateResult.NoResult();
        }

        var providedApiKey = apiKeyHeaderValues.FirstOrDefault();

        if (string.IsNullOrEmpty(providedApiKey))
        {
            return AuthenticateResult.NoResult();
        }

        var apiKey = await _apiKeyService.ValidateApiKeyAsync(
            providedApiKey, Context.RequestAborted);

        if (apiKey is null)
        {
            return AuthenticateResult.Fail("Invalid API key");
        }

        if (apiKey.ExpiresAt.HasValue && apiKey.ExpiresAt < DateTime.UtcNow)
        {
            return AuthenticateResult.Fail("API key has expired");
        }

        var claims = new List<Claim>
        {
            new(ClaimTypes.NameIdentifier, apiKey.Id),
            new("tid", apiKey.TenantId),
            new("api_key_name", apiKey.Name),
            new("auth_method", "api_key")
        };

        foreach (var scope in apiKey.Scopes)
        {
            claims.Add(new Claim("scope", scope));
        }

        var identity = new ClaimsIdentity(claims, Scheme.Name);
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, Scheme.Name);

        // Update last used
        await _apiKeyService.RecordUsageAsync(apiKey.Id, Context.RequestAborted);

        return AuthenticateResult.Success(ticket);
    }
}

public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions { }

17.8 Authorization Policies

// File: src/POS.Api/Extensions/AuthorizationExtensions.cs
using Microsoft.AspNetCore.Authorization;

namespace POS.Api.Extensions;

public static class AuthorizationExtensions
{
    public static IServiceCollection AddPosAuthorization(
        this IServiceCollection services)
    {
        services.AddAuthorization(options =>
        {
            // POS Operations
            options.AddPolicy("pos.sale.create",
                policy => policy.RequireClaim("permission", Permissions.PosSaleCreate));

            options.AddPolicy("pos.sale.void",
                policy => policy.RequireClaim("permission", Permissions.PosSaleVoid));

            options.AddPolicy("pos.sale.return",
                policy => policy.RequireClaim("permission", Permissions.PosSaleReturn));

            options.AddPolicy("pos.discount.apply",
                policy => policy.RequireClaim("permission", Permissions.PosDiscountApply));

            // Inventory Operations
            options.AddPolicy("inventory.view",
                policy => policy.RequireClaim("permission", Permissions.InventoryView));

            options.AddPolicy("inventory.adjust",
                policy => policy.RequireClaim("permission", Permissions.InventoryAdjust));

            // Catalog Operations
            options.AddPolicy("catalog.items.read",
                policy => policy.RequireClaim("permission", Permissions.CatalogItemView));

            options.AddPolicy("catalog.items.write",
                policy => policy.RequireClaim("permission", Permissions.CatalogItemCreate));

            options.AddPolicy("catalog.items.delete",
                policy => policy.RequireClaim("permission", Permissions.CatalogItemDelete));

            options.AddPolicy("catalog.items.bulk",
                policy => policy.RequireClaim("permission", Permissions.CatalogItemBulk));

            // Reports
            options.AddPolicy("reports.view",
                policy => policy.RequireClaim("permission", Permissions.ReportsView));

            // Admin
            options.AddPolicy("admin.settings",
                policy => policy.RequireClaim("permission", Permissions.AdminSettings));

            // Role-based policies
            options.AddPolicy("ManagerOrAbove",
                policy => policy.RequireRole(
                    Roles.Manager, Roles.Admin, Roles.Owner));

            options.AddPolicy("AdminOrOwner",
                policy => policy.RequireRole(Roles.Admin, Roles.Owner));

            // Integration API policy
            options.AddPolicy("api.integration",
                policy => policy.RequireAssertion(context =>
                    context.User.HasClaim("auth_method", "api_key") &&
                    context.User.HasClaim("scope", "integration")));
        });

        return services;
    }
}

17.9 Password Hashing

// File: src/POS.Infrastructure/Security/PasswordHasher.cs
using System.Security.Cryptography;

namespace POS.Infrastructure.Security;

public class PasswordHasher : IPasswordHasher
{
    private const int SaltSize = 16;
    private const int HashSize = 32;
    private const int Iterations = 100000;

    public string HashPassword(string password)
    {
        using var algorithm = new Rfc2898DeriveBytes(
            password,
            SaltSize,
            Iterations,
            HashAlgorithmName.SHA256);

        var salt = algorithm.Salt;
        var hash = algorithm.GetBytes(HashSize);

        var hashBytes = new byte[SaltSize + HashSize];
        Array.Copy(salt, 0, hashBytes, 0, SaltSize);
        Array.Copy(hash, 0, hashBytes, SaltSize, HashSize);

        return Convert.ToBase64String(hashBytes);
    }

    public bool VerifyPassword(string password, string hashedPassword)
    {
        var hashBytes = Convert.FromBase64String(hashedPassword);

        var salt = new byte[SaltSize];
        Array.Copy(hashBytes, 0, salt, 0, SaltSize);

        using var algorithm = new Rfc2898DeriveBytes(
            password,
            salt,
            Iterations,
            HashAlgorithmName.SHA256);

        var hash = algorithm.GetBytes(HashSize);

        for (var i = 0; i < HashSize; i++)
        {
            if (hashBytes[SaltSize + i] != hash[i])
                return false;
        }

        return true;
    }
}

public class PinHasher : IPinHasher
{
    public string HashPin(string pin)
    {
        using var sha256 = SHA256.Create();
        var bytes = System.Text.Encoding.UTF8.GetBytes(pin);
        var hash = sha256.ComputeHash(bytes);
        return Convert.ToBase64String(hash);
    }

    public bool VerifyPin(string pin, string hashedPin)
    {
        var hash = HashPin(pin);
        return hash == hashedPin;
    }
}

Summary

This chapter covered the complete security implementation:

  • Dual authentication flows: PIN for POS, Email/Password for Admin
  • JWT token structure with tenant, location, and permission claims
  • Role-based permission matrix from Cashier to Owner
  • Complete AuthController with all authentication endpoints
  • Tenant context middleware for multi-tenant isolation
  • API key authentication for external integrations

Next: Chapter 18 covers integration patterns for Shopify and payment processing.