Skip to content

Repository files navigation

BlogArray.SaaS Platform

Project Description

BlogArray.SaaS is an open-source multi-tenant SaaS platform designed to empower .NET developers to build, deploy, and manage scalable SaaS applications effortlessly. Built with ASP.NET Core 10, this platform leverages OpenIddict for identity management and Finbuckle.MultiTenant for multi-tenant support. It provides developers with a foundational solution for handling tenant management, authentication, authorization, and tenant-specific functionality, saving time and reducing complexity.

The platform consists of three main applications:

  1. BlogArray.SaaS.Identity: An identity server built on top of OpenIddict.
  2. BlogArray.SaaS.TenantSuite: A management application for tenants, users, roles, and scopes.
  3. BlogArray.SaaS.App: A demonstration of multi-tenant functionality. This application only supports Multiple Database - Complete Data Isolation. Feel free to customize the app for your desired approach.

Note: The project is currently in Proof of Concept (PoC) mode, so there may be occasional mistakes. Contributions and feedback are welcome.


Key Features

  • Multi-Tenant Support: Seamlessly manage multiple tenants using Finbuckle.MultiTenant.
  • Identity Management: Built-in identity server leveraging OpenIddict for authentication and authorization.
  • Scalability: Designed to support scalable SaaS applications.
  • Flexibility: Easily customizable for different business needs.
  • Caching: Supports SQL Server or Redis for optimized performance.
  • Tenant-Specific Media Storage: Save tenant-specific media files securely using Azure Blob Storage.

Technologies Used

  • ASP.NET Core 10
  • OpenIddict
  • Finbuckle.MultiTenant
  • Entity Framework Core
  • SQL Server / Redis (for caching)
  • Azure Blob Storage (for tenant-specific media storage)

Getting Started

To get started with BlogArray.SaaS, follow these steps:

Prerequisites

Ensure you have the following installed:

Installation Steps

  1. Clone the Repository

    git clone https://github.com/BlogArray/SaaS.git
  2. Navigate to the Project Directory

    cd BlogArray/SaaS
  3. Restore Dependencies

    dotnet restore
  4. Configure the Application Update the appsettings.json file in each application directory with the following configurations:

    {
      "AllowedHosts": "*",
      "IPSafeList": "127.0.0.1;192.168.1.5;::1",
      "ConnectionStrings": {
        "IdentityContext": "Data Source=.;Initial Catalog=BlogArray.SaaS.Identity;User Id=sa; Password=welcome;TrustServerCertificate=True;MultipleActiveResultSets=True"
      },
      "AzureBlobStorage": {
        "ConnectionString": "UseDevelopmentStorage=true",
        "ContainerName": "multi-tenant"
      },
      "Cache": {
        "Type": "SqlServer", //SqlServer or Redis
        "ConnectionString": "Data Source=.;Initial Catalog=DistCache;User Id=sa; Password=welcome;TrustServerCertificate=True;MultipleActiveResultSets=True",
        "SlidingExpirationInMinutes": 30,
        "AbsoluteExpirationInHours": 6
      },
      "Links": {
        "Suite": "https://www.console.blogarray.dev/",
        "Identity": "https://www.id.blogarray.dev/",
        "Admin": "https://www.admin.blogarray.dev/",
        "Issuer": "https://www.id.blogarray.dev/",
        "Authority": "https://www.id.blogarray.dev/"
      },
      "SMTP": {
        "FromEmail": "noreply@app.com",
        "FromName": "App Development",
        "Username": "localhost",
        "Password": "ttczmtxemkinbzxv",
        "Host": "localhost",
        "Port": 587,
        "EnableSsl": false
      },
      "Defaults": {
        "DefaultLogoUrl": "https://www.id.blogarray.dev/_content/BlogArray.SaaS.Resources/resources/images/blogarray-full-logo.png",
        "DefaultFaviconUrl": "https://www.id.blogarray.dev/_content/BlogArray.SaaS.Resources/resources/images/blogarray-icon.png"
      }
    }
  5. Create the OpenIddict Applications Seeding File The Identity application seeds OpenIddict clients from OpenIddictApplications.json. This file is not committed to the repository because it can contain secrets. Copy the provided template in src/Apps/BlogArray.SaaS.Identity/:

    cd src/Apps/BlogArray.SaaS.Identity
    cp OpenIddictApplications.template.json OpenIddictApplications.json

    The ClientSecret field is optional: when left empty, a cryptographically random client secret and API key are generated server-side at seeding time (retrieve them from the tenant administration console). Never commit the real OpenIddictApplications.json file.

  6. Apply Migrations Run the following command in each application directory that uses a database:

    dotnet ef database update
  7. Update Hosts File To enable a real-time experience, update the hosts file at C:\Windows\System32\drivers\etc\hosts with the following entries:

    127.0.0.1 blogarray.dev
    127.0.0.1 www.blogarray.dev
    127.0.0.1 app.blogarray.dev
    127.0.0.1 www.app.blogarray.dev
    127.0.0.1 id.blogarray.dev
    127.0.0.1 www.id.blogarray.dev
    127.0.0.1 console.blogarray.dev
    127.0.0.1 www.console.blogarray.dev
    127.0.0.1 admin.blogarray.dev
    127.0.0.1 www.admin.blogarray.dev
    127.0.0.1 auth.blogarray.dev
    127.0.0.1 www.auth.blogarray.dev
    
  8. Run Multiple Applications in Visual Studio

    • Open the BlogArray.SaaS.slnx solution in Visual Studio.
    • Set multiple startup projects by:
      1. Right-click the solution in Solution Explorer and select Properties.
      2. In the Common Properties -> Startup Project tab, choose Multiple startup projects.
      3. Set the Action to Start for BlogArray.SaaS.Identity, BlogArray.SaaS.TenantSuite, and BlogArray.SaaS.App.
      4. Click OK.
    • Press F5 to run all applications simultaneously. Each application will launch in its configured domain.

Configuration

  • Multi-Tenant Configuration: Define tenants in the appsettings.json of the TenantSuite application.

  • Identity Server: Configure client secrets and scopes in BlogArray.SaaS.Identity.

  • Caching: Enable and configure either SQL Server or Redis for caching in the appsettings.json.

  • Azure Blob Storage: Configure the Azure Blob Storage connection string and container name in appsettings.json for tenant-specific media storage.

  • Multi-Tenant Strategy Configuration: BlogArray.SaaS uses Finbuckle.MultiTenant's Route strategy for tenant identification by default. You can customize the strategy as per your requirements by referring to the Finbuckle.MultiTenant Documentation. Example of switching to the Host strategy:

    builder.Services.AddMultiTenant<AppTenantInfo>()
        .WithHostStrategy()
        .WithDistributedCacheStore(TimeSpan.FromMinutes(5))
        .WithPerTenantAuthentication();

    Refer to the documentation for more details and supported strategies.

  • CAPTCHA (Cloudflare Turnstile): Set Captcha:SiteKey and Captcha:SecretKey in the Identity application to enable the challenge on login, forgot/reset password, resend-confirmation and recovery-code pages. Empty keys (default) disable it.

  • CORS: Set Cors:AllowedOrigins (semicolon-separated) on any application that must accept browser requests from another origin. Empty (default) = no cross-origin access.

  • Tenant SQL host allow-list: Set Tenants:AllowedSqlHosts (semicolon-separated) on TenantSuite/Identity to restrict which SQL Server hosts tenant connection strings may target. Empty (default) = any host (development only).

  • Passkey origins: Set Fido2:Origins (semicolon-separated) in the Identity application to accept additional origins for passkey ceremonies beyond Links:Issuer.

  • Password policy extras: Passwords:HistorySize (remembered previous passwords, default 5) and Passwords:BlockBreachedPasswords (reject passwords found in known data breaches, default true) in the Identity/TenantSuite applications.


Security

The platform ships with a hardened authentication stack: passkeys (WebAuthn) as a full passwordless sign-in method, email one-time codes as a second factor, CAPTCHA step-up, per-device session management with single sign-out, tenant-bound API keys, and SAML SSO with spec-depth assertion validation. This section covers the configuration and behaviors you should know about.

Bootstrap Superuser Credential

The seeded admin@blogarray.net Superuser account ships without a password and is flagged to change its password at first sign-in. Set the initial password through either:

  • the Forgot password flow (requires a working email sender), or
  • the TenantSuite user management Reset password action ("create a temporary password on behalf"), which forces the user to set a new password at the next sign-in.

OpenIddictApplications.json Seeding

The Identity application seeds OIDC clients from OpenIddictApplications.json. This file is not committed (it carries environment-specific client secrets and is gitignored) - copy the tracked template on a fresh clone:

  • cp src/Apps/BlogArray.SaaS.Identity/OpenIddictApplications.template.json src/Apps/BlogArray.SaaS.Identity/OpenIddictApplications.json
  • When the file is absent, seeding is skipped with a startup warning instead of failing.

Credential handling:

  • Keep development secrets only in this file - never reuse them in production.
  • When ClientSecret is omitted, a cryptographically random secret and API key are generated server-side at seeding time (retrieve them from the tenant administration console).
  • Rotate any secret that has ever been committed to a public repository.

API Keys Are Bound to Their Tenant

The Membership API (api/membership) resolves the tenant from the presented X-API-Key and rejects requests (HTTP 403) whose body names a different tenant. One tenant's API key can no longer invite, assign, or remove users in another tenant.

Production Token Signing and Encryption Certificates

The Identity application signs and encrypts tokens with X.509 certificates. Configure them per environment:

  • Local development (Windows): self-signed certificates in the current user's certificate store, referenced by thumbprint in appsettings.Development.json.
  • Production: the same certificates imported into the server's LocalMachine\My store (or CA-issued certificates), referenced in the production configuration.

Creating the certificates (Windows PowerShell)

$notAfter = (Get-Date).AddYears(10)

$signing = New-SelfSignedCertificate -Subject "CN=BlogArray.SaaS Token Signing" `
    -FriendlyName "BlogArray.SaaS Token Signing" `
    -KeyAlgorithm RSA -KeyLength 4096 -KeyExportPolicy Exportable `
    -KeyUsage DigitalSignature -KeySpec Signature `
    -NotAfter $notAfter -CertStoreLocation "Cert:\CurrentUser\My"

$encryption = New-SelfSignedCertificate -Subject "CN=BlogArray.SaaS Token Encryption" `
    -FriendlyName "BlogArray.SaaS Token Encryption" `
    -KeyAlgorithm RSA -KeyLength 4096 -KeyExportPolicy Exportable `
    -KeyUsage KeyEncipherment,DataEncipherment -KeySpec KeyExchange `
    -NotAfter $notAfter -CertStoreLocation "Cert:\CurrentUser\My"

"Signing:    $($signing.Thumbprint)"
"Encryption: $($encryption.Thumbprint)"

Back up the certificates as PFX files (store these somewhere safe - never commit them):

$certsDir = "src\Apps\BlogArray.SaaS.Identity\certs"   # this folder is gitignored
New-Item -ItemType Directory -Force -Path $certsDir | Out-Null

$passwordChars = 1..48 | ForEach-Object { '{0:x}' -f (Get-Random -Maximum 16) }
$pfxPassword = ConvertTo-SecureString -String (-join $passwordChars) -Force -AsPlainText

Export-PfxCertificate -Cert $signing    -FilePath "$certsDir\blogarray-token-signing.pfx"    -Password $pfxPassword
Export-PfxCertificate -Cert $encryption -FilePath "$certsDir\blogarray-token-encryption.pfx" -Password $pfxPassword

Configuration

Reference the certificates by thumbprint. For local development, add to appsettings.Development.json:

{
  "OpenIddict": {
    "SigningCertificate": {
      "Thumbprint": "<signing thumbprint from above>"
    },
    "EncryptionCertificate": {
      "Thumbprint": "<encryption thumbprint from above>"
    }
  }
}
  • Thumbprint searches the CurrentUser and LocalMachine My certificate stores.
  • Path + Password loads a PFX file instead (useful on servers where you prefer file-based keys).

When both certificates are configured, access tokens are also encrypted. Without certificates the server falls back to ephemeral keys and prints a CRITICAL warning: tokens are invalidated on every restart and this is not safe for multi-instance deployments.

Running the Identity app under IIS (local)

The IIS application pool runs under a different account and may not see your user's CurrentUser store. Run one elevated PowerShell to make the certificates machine-wide:

# Run this PowerShell as Administrator
Move-Item "Cert:\CurrentUser\My\<SIGNING THUMBPRINT>"    "Cert:\LocalMachine\My"
Move-Item "Cert:\CurrentUser\My\<ENCRYPTION THUMBPRINT>" "Cert:\LocalMachine\My"

(The application pool reads LocalMachine\My without extra permissions for standard machine keys.)

Deploying to production servers

  1. Copy the two PFX backups to the server (via your secret-management process).
  2. Import them into the machine store:
Import-PfxCertificate -FilePath .\blogarray-token-signing.pfx `
    -CertStoreLocation Cert:\LocalMachine\My `
    -Password (Read-Host -AsSecureString "PFX password")

Import-PfxCertificate -FilePath .\blogarray-token-encryption.pfx `
    -CertStoreLocation Cert:\LocalMachine\My `
    -Password (Read-Host -AsSecureString "PFX password")
  1. Grant the application pool's identity read access to the private keys:
foreach ($thumb in @("<SIGNING THUMBPRINT>", "<ENCRYPTION THUMBPRINT>")) {
    $cert = Get-ChildItem Cert:\LocalMachine\My | Where-Object Thumbprint -eq $thumb
    $key = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($cert)
    $uniqueName = if ($key.Key.GetType().Name -eq "RSACng") { $key.Key.UniqueName } else { $key.CspKeyContainerInfo.UniqueKeyContainerName }
    $folder = if ($key.Key.GetType().Name -eq "RSACng") { "$env:ProgramData\Microsoft\Crypto\SystemKeys" } else { "$env:ProgramData\Microsoft\Crypto\RSA\MachineKeys" }
    icacls "$folder\$uniqueName" /grant "IIS_IUSRS:(R)"
}
  1. Fill in the thumbprints in the production configuration and restart the application.

Self-signed certificates are acceptable for token signing because the relying party's trust is pinned to the certificate itself (via the security.txt/discovery JWKS), not to a CA chain. Renew or replace before the 10-year validity ends.

Personnel Management Requires a Role

PersonnelsController in BlogArray.SaaS.App (which creates identity users and grants tenant access) now requires the TenantAdmin or Superuser role. Grant users the TenantAdmin role in the tenant suite before they can manage personnel.

Tenant API Keys

API keys are never stored in plaintext: validation compares a SHA-256 hash, tenant apps read a DataProtection-protected copy, and only a short display prefix is shown in the admin UI. Tenant credentials (client secret and API key) are emailed to the tenant admin addresses on creation and on API key rotation; a delivery failure never blocks the operation because the secrets are also shown once in the browser.

Setting Purpose
ApiKey:PrefixLength Number of leading key characters kept for display (default 8). Change per environment without affecting already-stored keys.
DataProtection:Mode Local (self-hosted/IIS, ring persisted in the master database) or AzureKeyVault (App Service/multi-instance, uses BlobUri plus optional KeyVaultKeyId).
DataProtection:BlobUri Azure blob URI persisting the key ring in AzureKeyVault mode. See the Azure App Service section below.
DataProtection:KeyVaultKeyId Optional version-less Key Vault key URI encrypting the persisted ring at rest (used in AzureKeyVault mode).
DataProtection:KeyLifetimeDays Days a generated key protects new payloads before DataProtection rolls to a fresh one (default 90). Expiration never affects decryption: expired keys stay in the ring forever, so already-protected payloads keep unprotecting.

The DataProtection key ring (Local mode)

In Local mode the ring is stored in the master database's DataProtectionKeys table (created by the AddDataProtectionKeys migration; EnsureCreated covers brand-new databases). There is no key file to create and no folder/ACL setup: the ring is generated automatically on first use, is shared by all three apps through the shared database, and is backed up together with the regular database backups. DataProtection rotates keys automatically every 90 days and keeps the old ones for decryption, so backups stay valid across rotations.

The key ring is never stored on local disk. Local mode persists it to the master database and AzureKeyVault mode to Azure blob storage (optionally Key Vault-encrypted) - the database/storage backup is therefore the only backup that matters. Never delete old rows from the ring store: expired keys are retained for decryption by design, and removing them makes payloads encrypted with those keys unreadable.

Azure App Service

App Service's built-in DataProtection persistence (%HOME%\data\.aspnet\DataProtection-Keys) is per app - Identity, TenantSuite and App would each get their own ring and could not decrypt each other's payloads. All three apps must therefore share one explicit store.

Recommended - Mode: Local (database ring, zero extra infrastructure):

All three apps already share the master database, so the DataProtectionKeys table is a shared, backed-up ring out of the box on App Service. Just leave DataProtection:Mode at Local (or set DataProtection__Mode = Local) - no mounts, no blob, no Key Vault. Back up the database as usual.

Optional - Mode: AzureKeyVault (ring outside the database, encrypted by Key Vault):

  1. Create a storage account blob container (e.g. dataprotection) and a Key Vault key (version-less).
  2. Choose authentication:
    • Managed identity: enable a system-assigned identity on all three App Services and grant each Storage Blob Data Contributor on the storage account and Key Vault Crypto User on the vault. Set DataProtection__BlobUri to the plain blob URI (e.g. https://<account>.blob.core.windows.net/dataprotection/keys.xml).
    • SAS: set DataProtection__BlobUri to the blob URI with a SAS token in its query string (no roles needed).
  3. Optional but recommended - set DataProtection__KeyVaultKeyId to the version-less key URI (e.g. https://<vault>.vault.azure.net/keys/dataprotection) so keys are encrypted at rest with Key Vault.
  4. Set the app settings on all three apps:
DataProtection__Mode          = AzureKeyVault
DataProtection__BlobUri       = https://<account>.blob.core.windows.net/dataprotection/keys.xml
DataProtection__KeyVaultKeyId = https://<vault>.vault.azure.net/keys/dataprotection

ConfigureBlogArrayServices fails fast at startup when Mode is AzureKeyVault but BlobUri is missing.

Upgrade note: the tenant secrets (client secret, connection string) are stored DataProtection-protected; the plaintext columns were dropped. Two-step upgrade: deploy the commit that adds the protected columns and the startup conversion sweep first, let every app start once (the sweep converts existing rows), then deploy the commit that drops the plaintext columns. Jumping straight to the final schema loses pre-existing client secrets and connection strings - re-enter them from the tenant administration screens (and rotate the API key if needed).

Authentication Methods

Method Description
Password + 2FA Email/password sign-in with TOTP authenticator, recovery codes, or an emailed one-time code as the second factor.
Passkeys (WebAuthn) Full passwordless sign-in: register a passkey in Settings → Passkeys, then use the native browser/OS prompt (biometric/PIN) from the login page. Passkeys use discoverable credentials with required user verification and are independent of traditional 2FA and its enable/disable state.
SAML SSO (per tenant) Tenants with SSO enabled delegate sign-in to their own identity provider. SAML responses are validated for signature, audience, recipient, request correlation (InResponseTo) and expiry.

SAML note: encrypted assertions are not supported. Configure the tenant identity provider to issue plain (unsigned-encryption-off) assertions; encrypted assertions are rejected with an error. Adding support is tracked in the backlog (per-tenant encryption certificate + decryption support). | External/social providers | Microsoft, Google, GitHub and Apple, each enabled via Authentication:*:Enabled flags. 2FA is never bypassed for social sign-ins. |

All sign-ins are recorded in Settings → Security activity.

Session Management

Every application sign-in creates a tracked session (device, browser, IP). Users can review and revoke their sessions under Settings → Where you're signed in, including signing out individual devices or all other devices - revoked sessions are rejected server-side on the next request.

Logging out of the Identity application revokes all tokens for the user and, for tenants with Single logout enabled, signs the user out of the connected tenant applications.

CAPTCHA (Cloudflare Turnstile)

Set Captcha:SiteKey and Captcha:SecretKey in the Identity application to enable the Turnstile challenge on the login page and on the "email me a code" request during two-factor sign-in. Empty keys (default) disable it entirely. Verification fails open if Cloudflare is unreachable, so an outage cannot block sign-ins.


Running the Applications

  • BlogArray.SaaS.Identity: Provides authentication and token issuance.
  • BlogArray.SaaS.TenantSuite: Manage tenants, users, roles, and scopes.
  • BlogArray.SaaS.App: Demonstrates tenant-specific functionality. This application only supports Multiple Database - Complete Data Isolation.

Run each application individually or all together using Visual Studio.


Contributing

We welcome contributions to improve BlogArray.SaaS! To contribute:

  1. Fork the repository.
  2. Create a new branch: git checkout -b feature/your-feature-name.
  3. Commit your changes: git commit -m 'Add your feature'.
  4. Push the branch: git push origin feature/your-feature-name.
  5. Open a pull request.

For detailed guidelines, see the CONTRIBUTING.md.


License

This project is licensed under the MIT License.


Acknowledgments

Special thanks to the creators and maintainers of:


We hope BlogArray.SaaS helps you kickstart your SaaS development journey. If you have any questions or encounter issues, feel free to open an issue in the repository!

About

An open-source multi-tenant SaaS platform built with ASP.NET Core, empowering .NET developers to build, deploy, and manage scalable SaaS applications effortlessly.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages