Skip to content

ADR-001: Authentication and Multi-Tenancy

Status: Ready for Implementation

Last Updated: 2025-11-16

Target: Support OAuth authentication (Google, future: Microsoft, Facebook) with account-based multi-tenancy

Overview

This document outlines the implementation plan for adding authentication and multi-tenancy to Petfolio Service. The solution will:

  • Support OAuth providers (starting with Google)
  • No password storage - OAuth only
  • Link Users to Accounts with proper isolation
  • Link Animals to Accounts for multi-tenancy
  • One OAuth provider per user (Google OR Microsoft, not both)
  • Support account invitations and multiple users per account
  • Email addresses are globally unique (one account per email)

Architecture Decisions

  1. Authentication Strategy: OAuth + Standard ASP.NET Core JWT Bearer authentication

    • OAuth providers handle user authentication (Google, future: Microsoft, Facebook)
    • JWT tokens for stateless authentication
    • No ASP.NET Core Identity - keeping it simple for OAuth-only scenarios
  2. Identity Model: OAuth Information Stored Directly on User Entity

    • OAuth provider type and provider user ID stored as properties on User entity
    • One OAuth provider per user (user picks Google OR Microsoft, not both)
    • Simpler than separate UserIdentity entity - no extra table needed
    • Users belong to exactly one account (AccountId on User)
    • Accounts can have multiple users (supports family sharing via invitations)
    • Email addresses are globally unique across entire system
    • Trade-offs:
      • Users cannot link multiple OAuth providers (must choose Google OR Microsoft)
      • Users cannot switch providers without data migration
      • Email is globally unique (user cannot exist in multiple accounts)
  3. Multi-Tenancy: Manual Global Query Filters (EF Core)

    • Built-in EF Core feature - no external dependencies
    • Automatic query filtering via HasQueryFilter()
    • Manual SaveChanges override to set AccountId on inserts/updates
    • Simple, transparent, and easy to debug
    • Full control over tenant isolation logic
    • Appropriate for small-medium entity count (< 20 multi-tenant entities)

Justification

Simplicity: OAuth-only means no password management complexity. ASP.NET Core Identity is designed for password-based authentication and adds 7+ tables and significant complexity you don't need.

Security: Delegating to proven components:

  • Google/Microsoft handle user authentication
  • ASP.NET Core handles JWT validation (built-in)
  • EF Core handles multi-tenancy isolation (built-in Global Query Filters)
  • Minimal external dependencies reduces attack surface

Transparency: Manual implementation means:

  • Full visibility into tenant isolation logic
  • Easy to debug and understand
  • No "magic" happening behind the scenes
  • Simple to test and verify security

Future-proof: Clean architecture with domain entities means you can swap infrastructure later if needed. Can migrate to Finbuckle or other solutions if complexity grows.

Alternatives considered

Authentication & Authorisation

  • Alternative 1: ASP.NET Core Identity + External Providers
  • What: Microsoft's full-featured authentication framework with built-in support for password management, roles, two-factor authentication, and external OAuth providers.
  • Why not:
    • Unnecessary complexity - Adds 7+ database tables
    • Built primarily for password-based authentication with OAuth as an add-on. We don't want to manage any passwords in house
    • More features than we need, password hashing, reset flows, and email verification features go unused
    • Requires adapter layer to maintain clean architecture, adding boilerplate
  • Alternative 2: External Auth Service (Auth0, Firebase Auth, Okta)
  • What: Fully managed authentication-as-a-service platforms that handle login UI, user storage, OAuth flows, and token generation.
  • Why not:
    • Could get expensive
    • Limited customisation
    • Unnecessary complexity for our requirements
    • User data stored externally, potential GDPR concerns
  • Alternative 3: Manual JWT + OAuth Implementation (DIY)
  • What: Building authentication from scratch - implementing OAuth flows manually, generating JWTs, managing refresh tokens, handling user sessions
  • Why not:
    • We will have full responsibility for the security of the data
    • Big time investment required
    • Reinventing the wheel - .NET Core's JWT Bearer middleware already solves this
  • Alternative 4: Keycloak (Self-Hosted Open-Source Identity Provider)
  • What: Open-source Identity and Access Management (IAM) solution, now a CNCF project (v26.5+). Provides OAuth 2.0/OIDC, built-in social login connectors (Google, Microsoft, Facebook), user management admin console, Organizations feature for multi-tenancy, MFA, and SAML support. Handles both password-based and OAuth authentication natively.
  • Why not:
    • Significant operational overhead: 1.5-2GB RAM Java/Quarkus service requiring its own PostgreSQL database, monitoring, patching, and quarterly upgrades
    • Login UI redirect: Users leave the Next.js app and are redirected to Keycloak's hosted login page. Custom login/signup wireframes would need reimplementation as Keycloak Freemarker themes (Java templating), not React components
    • Overkill for current scale: Designed for enterprise IAM scenarios (SSO, LDAP federation, SAML, fine-grained authorization policies). A pet portfolio SaaS with hundreds of accounts does not need this complexity
    • Does NOT replace data-level multi-tenancy: Keycloak Organizations handles identity-level tenancy (user-to-org membership) but EF Core Global Query Filters are still required for Animal/Account data isolation
    • Local development complexity: Every developer needs Keycloak + PostgreSQL running via Docker Compose, adding setup time and ~2GB RAM to local environment
    • Java/Quarkus expertise required for theme customisation, debugging, and JVM tuning
  • When to reconsider:
    • Enterprise customers request SSO (SAML/OIDC with their corporate Identity Provider)
    • 5+ OAuth providers needed and maintaining individual validators becomes burdensome
    • Regulatory requirements demand centralised identity auditing and compliance features
    • User base grows to 10,000+ requiring advanced features like MFA, brute-force detection, or adaptive authentication
    • Multi-product architecture where multiple applications need shared authentication (SSO)

Identity Model Strategy

  • Alternative 1: Separate UserIdentity Entity
  • What: Create a separate UserIdentity entity with one-to-many relationship to User (one user can have multiple OAuth identities)
  • Why not:
    • More complex - adds extra table and repository
    • Overkill if we only support one provider per user
    • More database joins during authentication
  • When to reconsider:

    • If users frequently request ability to link Google + Microsoft + Facebook
    • If we want to support provider switching without data migration
    • If we implement cross-account user membership (users in multiple accounts)
  • Alternative 2: ASP.NET Core Identity UserLogins Table

  • What: Use ASP.NET Core Identity's built-in external login management
  • Why not:
    • Forces us to use entire Identity framework (7+ tables)
    • Designed for password + OAuth hybrid scenarios
    • Conflicts with clean architecture principles
    • More complexity than we need for OAuth-only

Multi-Tenancy Strategy

  • Alternative 1: Finbuckle.MultiTenant
    • What: Third-party library that provides advanced multi-tenancy features including automatic tenant resolution, per-tenant services, and database-per-tenant support.
    • Why not:
      • Overkill for our current needs (only 4-5 entities need tenant isolation)
      • Adds external dependency and complexity
      • More "magic" happening behind the scenes - harder to debug
      • Designed for larger, more complex multi-tenancy scenarios
      • When to reconsider: If we grow to 20+ multi-tenant entities or need advanced features like tenant switching/per-tenant services
  • Alternative 2: Repository-Level Filtering
  • What: Implementing tenant filtering manually in each repository method by always including .Where(e => e.AccountId == currentAccountId).
  • Why not:
    • Every single repository method would need manual filtering (~100+ places)
    • Easy to forget - high risk of data leakage
    • Developers can bypass repositories and use DbContext directly
    • Tedious to maintain as entities grow
  • Alternative 3: Separate database per tenant
  • What: Each tenant gets their own complete database instance. No shared tables, no AccountId columns needed.
  • Why not:
    • Operational complexity - Schema migrations must run against every tenant database. What happens when migration #47 fails? Partial deployment, rollback complexity?
    • High costs - need to provision and maintain thousands of databases
    • Maintenance burden - backups, monitoring, updates multiply by tenant count
    • Overkill for our projected scale (hundreds of accounts, not enterprise thousands)

Considerations

Design Decisions

  • OAuth-only authentication: The wireframes originally included email/password fields, but the decision was made to launch with OAuth-only (Google + Microsoft). This dramatically simplifies the backend (no password hashing, email verification, forgot-password flows, or brute-force protection). Wireframes should be updated to reflect OAuth-only login and signup. Password-based authentication can be added later if user feedback shows demand.
  • Business accounts first, personal later: The current signup flow and Account entity are designed for business accounts (business name, type). The authentication layer is intentionally account-type agnostic — a personal account is simply an Account with one user and no business metadata. A separate personal signup flow can be added later without changes to the authentication or multi-tenancy architecture.

Risks

  • Single OAuth provider per user
  • Risk: User signs up with Google, later wants to use Facebook with same email
  • Current limitation: Not supported - user locked to chosen provider
  • Mitigation: Clear messaging during signup about provider choice
  • Workaround: Contact support for manual data migration
  • Future migration: Can add UserIdentity entity if this becomes frequent user request

  • OAuth provider account deletion

  • Risk: User's Google account gets deleted/disabled - loses access to Petfolio
  • Mitigation:
    • Document this limitation in terms of service
    • Implement admin override for account recovery
    • Consider adding email/password fallback in future
  • Workaround: Manual data transfer to new OAuth account by support team

  • Email global uniqueness constraints

  • Risk: alice@gmail.com exists in Account A, Account B tries to invite alice@gmail.com - invitation fails
  • Current limitation: Email can only exist in one account across entire system
  • Mitigation:
    • Clear error message: "This email is already registered"
    • Suggest using email aliases (alice+family@gmail.com)
  • Impact: Users cannot be members of multiple accounts with same email

  • No cross-account user membership

  • Risk: Bob wants personal account + access to family account - must use different emails
  • Current limitation: Each user (email) belongs to exactly ONE account
  • Mitigation:
    • Document in user guide
    • Suggest using email aliases for different accounts
  • Alternative approach: Share login credentials (not recommended for security)

  • No provider switching

  • Risk: User wants to switch from Google OAuth to Microsoft OAuth
  • Current limitation: Provider is immutable after account creation
  • Mitigation:
    • Make provider choice clear during signup
    • Provide admin support for switching (manual database update)
  • Future migration: UserIdentity entity would enable this

  • Manual multi-tenancy implementation

  • Risk: Forgetting to add query filter for new multi-tenant entities
  • Mitigation:
    • Clear documentation and patterns to follow
    • Integration tests verify tenant isolation
    • Create base interface IMultiTenant to make it explicit
  • Future migration path: Can migrate to Finbuckle if entity count grows significantly (20+)

  • SaveChanges override complexity

  • Risk: Edge cases where AccountId isn't set properly on inserts
  • Mitigation:

    • Throw exception if AccountId is null/empty on insert
    • Unit tests verify AccountId is set
    • ICurrentUserService always available in scoped context
  • OAuth provider dependency

  • Risk: If Google changes their OAuth API significantly
  • Mitigation:

    • Using standard OAuth 2.0, multiple providers planned (Microsoft, Facebook)
    • OAuth is stable, breaking changes are rare
  • Shared database scale limits

  • Risk: At massive scale (millions of tenants), might need sharding
  • Mitigation: Most SaaS apps never hit this limit; can migrate high-value tenants to dedicated DBs later

  • No password fallback

  • Risk: Users without Google accounts cannot sign up
  • Mitigation: 90%+ of users have Google/Microsoft accounts; can add email/password later if needed
  • Current decision: Simplicity now, complexity later if required

Security

Identified Risks and Mitigations

Risk Severity Mitigation
JWT signing key compromise — Symmetric key (HMAC-SHA256), if leaked, allows forging any JWT Medium-High Use asymmetric keys (RSA/ECDSA) — backend signs with private key, validates with public key. A leaked public key cannot forge tokens. Store private key in Azure Key Vault / AWS KMS with automatic key rotation.
No token revocation — Stateless JWTs cannot be invalidated before expiry Medium-High Short-lived access tokens (15 min) + database-backed refresh tokens (7 days). Revoke the refresh token to lock out compromised accounts. Access token expires naturally within 15 minutes.
Google tokeninfo endpoint dependency — Network call to Google on every login; if Google is down, logins fail Medium Validate Google JWTs locally using Google's JWKS (public keys at googleapis.com/oauth2/v3/certs). Cache keys locally. ASP.NET Core's AddJwtBearer supports this natively via the Authority configuration.
No rate limiting on auth endpoints/api/auth/* endpoints vulnerable to abuse Medium Use ASP.NET Core's built-in AddRateLimiter() middleware. Apply a fixed-window or sliding-window policy to auth endpoints (e.g., 10 requests/minute per IP).
Email sync drift — If user changes email at OAuth provider, our database retains the old email Low Update stored email from OAuth claims on each login. Compare the email from the provider's token with the stored email and update if changed (with unique constraint check).
AccountId visible in JWT — JWTs are base64-encoded (signed but not encrypted), so AccountId is readable Low Accept the risk — AccountId is a GUID with no meaning outside the system. If this becomes a concern, switch to JWE (encrypted JWT), but this adds complexity for minimal benefit.

Security Configuration

  • Token expiration: 15-minute access tokens, 7-day refresh tokens
  • Google token validation: Validate locally using JWKS (cached public keys), not the tokeninfo endpoint
  • CORS: Configure for frontend domain only (WithOrigins())
  • Secrets management:
    • Development: Use User Secrets (dotnet user-secrets set "JwtSettings:PrivateKey" "your-key")
    • Production: Use Azure Key Vault or AWS Secrets Manager with automatic key rotation
  • Rate limiting: ASP.NET Core AddRateLimiter() on all auth endpoints
  • Logging: Log authentication attempts with IP and provider (never log tokens or secrets)
  • HTTPS only: Enforce in production (app.UseHttpsRedirection())

Best Practices

  • Validate JWT signature on every request (asymmetric key verification)
  • Short-lived access tokens (15 min) with single-use rotating refresh tokens
  • Secure cookie flags if using cookies (HttpOnly, Secure, SameSite=Strict)
  • SQL injection protection (EF Core parameterises queries)
  • XSS protection (sanitise inputs, encode outputs)
  • CSRF protection for cookie-based auth

GDPR and Data Protection

Personal Data Stored

Data Classification Lawful Basis Retention
Email address Personal data Contract (providing the service) Until account deletion
Name (from OAuth provider) Personal data Contract Until account deletion
OAuth provider user ID Pseudonymous identifier Contract Until account deletion
Account membership Linked to individual Contract Until account deletion
Animals and pet data Linked to account Contract Until account deletion

Data Subject Rights (Required)

  • Right to Access (Article 15): Users can request all data held about them. Implement a data export endpoint returning JSON.
  • Right to Erasure (Article 17): Users can request deletion of their account and ALL associated data (User, Account if sole owner, Animals, etc.). The DeleteBehavior.Restrict on entities means explicit cascade-or-archive logic is required.
  • Right to Portability (Article 20): Users can request their data in a machine-readable format (JSON export).
  • Data Breach Notification (Article 33): Must notify authorities within 72 hours of discovering a personal data breach.
  • Consent at Signup: Users must agree to privacy policy and terms of service before account creation. Record the timestamp and version of consent.

What Is NOT a GDPR Concern

  • Google/Microsoft storing user data for OAuth — that is their responsibility as a separate data controller
  • OAuth tokens — we validate and discard them, never stored
  • JWT tokens — transient, stored client-side, contain minimal claims

Data Minimisation

The current design follows the data minimisation principle — we store only the minimum data required:

  • No passwords stored (OAuth-only)
  • No unnecessary profile data beyond what the OAuth provider supplies
  • Animal data is user-initiated and required for the service

Implementation Guide

For full implementation details including code examples, work breakdown structure, and phase-by-phase guidance, see the Implementation Guide. The feature backlog is tracked in PET-103 on Jira.


References

Core Documentation

Architecture Patterns

Security Best Practices