Skip to content

Authentication & Multi-Tenancy

Petfolio uses OAuth-only authentication (no passwords) with account-based multi-tenancy. Users sign in with Google (with Microsoft and Facebook planned), and all their data is scoped to the account they belong to.

This page covers what the auth flow looks like for users, and how it works technically.

Design decisions

For the full rationale, alternatives considered, and security analysis, see ADR-001: Authentication and Multi-Tenancy.


User Flow

Signing Up (New User)

A new user arrives at Petfolio and creates an account through their OAuth provider.

sequenceDiagram
    actor User
    participant FE as Petfolio Frontend
    participant Google as Google OAuth
    participant API as Petfolio API
    participant DB as Database

    User->>FE: Clicks "Sign up with Google"
    FE->>Google: Redirects to Google consent screen
    Google->>User: "Allow Petfolio to access your profile?"
    User->>Google: Grants consent
    Google->>FE: Returns OAuth token
    FE->>API: POST /api/auth/signup with Google token + account details
    API->>Google: Validates token (using Google's public keys)
    API->>DB: Creates Account + User record
    API->>FE: Returns JWT access token + refresh token
    FE->>User: Redirected to dashboard

What happens behind the scenes:

  1. Google authenticates the user and confirms their identity
  2. The API validates the Google token locally (no network call to Google on every request)
  3. A new Account is created (the tenant boundary for all their data)
  4. A new User is created and linked to the Account, storing:
    • Their name and email (from Google's profile)
    • Their Google provider ID (so we can recognise them next time)
  5. The API issues a JWT access token (short-lived, 15 minutes) and a refresh token (7 days)

Logging In (Returning User)

A returning user authenticates with their OAuth provider to get a new session.

sequenceDiagram
    actor User
    participant FE as Petfolio Frontend
    participant Google as Google OAuth
    participant API as Petfolio API
    participant DB as Database

    User->>FE: Clicks "Log in with Google"
    FE->>Google: Redirects to Google
    Google->>FE: Returns OAuth token
    FE->>API: POST /api/auth/login with Google token
    API->>Google: Validates token
    API->>DB: Looks up User by provider + provider ID
    DB->>API: Returns existing User + Account
    API->>FE: Returns JWT access token + refresh token
    FE->>User: Redirected to dashboard

Key difference from signup: No new records are created. The API looks up the user by their OAuth provider and provider user ID to find their existing account.

Making Authenticated Requests

Once logged in, every API request includes the JWT token. The user never interacts with auth again until the token expires.

sequenceDiagram
    actor User
    participant FE as Petfolio Frontend
    participant API as Petfolio API
    participant DB as Database

    User->>FE: Views their animals
    FE->>API: GET /api/animals (with JWT in header)
    API->>API: Validates JWT signature
    API->>API: Extracts UserId, AccountId, Email from claims
    API->>DB: Queries animals WHERE AccountId = user's account
    DB->>API: Returns only this account's animals
    API->>FE: Animal list
    FE->>User: Shows their animals

Multi-tenancy in action: The database automatically filters results by the authenticated user's AccountId. A user in Account A can never see data belonging to Account B.

Token Refresh

Access tokens expire after 15 minutes. The frontend silently refreshes them using the refresh token.

sequenceDiagram
    actor User
    participant FE as Petfolio Frontend
    participant API as Petfolio API

    FE->>API: GET /api/animals (with expired JWT)
    API->>FE: 401 Unauthorised
    FE->>API: POST /api/auth/refresh (with refresh token)
    API->>API: Validates refresh token
    API->>FE: New JWT access token + new refresh token
    FE->>API: GET /api/animals (with new JWT)
    API->>FE: Animal list
    Note over User: User sees no interruption

The user never notices this happening. If the refresh token itself has expired (after 7 days), the user is redirected to log in again.


Technical Overview

How the Pieces Fit Together

graph TB
    subgraph "Browser"
        FE[Frontend App]
    end

    subgraph "OAuth Provider"
        G[Google / Microsoft / Facebook]
    end

    subgraph "Petfolio API"
        direction TB
        MW[JWT Middleware]
        HCA[HttpContextClaimsPrincipalAccessor]
        CUS[CurrentUserService]
        MED[MediatR Pipeline]
        HAN[Command / Query Handlers]
        DB[DbContext + Global Query Filters]
    end

    subgraph "Database"
        MySQL[(MySQL)]
    end

    FE -- "Bearer token in header" --> MW
    FE -- "OAuth token at login" --> G
    G -- "Validated identity" --> FE

    MW -- "Validates JWT, populates ClaimsPrincipal" --> HCA
    HCA -- "Extracts ClaimsPrincipal from HttpContext" --> CUS
    CUS -- "Provides UserId, AccountId, Email" --> MED
    MED -- "Validates + dispatches" --> HAN
    HAN --> DB
    DB -- "Filters by AccountId automatically" --> MySQL

    style MW fill:#e3f2fd
    style CUS fill:#e8f5e9
    style DB fill:#fff3e0

JWT Claims

When a user is authenticated, the JWT token contains three claims that Petfolio uses:

Claim Example Used For
sub (NameIdentifier) google\|123456789 Identifying which user is making the request
accountId 550e8400-e29b-41d4-a716-446655440000 Filtering data to the correct tenant
email alice@example.com Display and communication

The accountId claim is the linchpin of multi-tenancy -- it determines which data the user can see and modify.

Request Lifecycle

Here is what happens for every authenticated API request, from HTTP to database:

1. HTTP request arrives with "Authorization: Bearer <jwt>" header
        |
2. ASP.NET Core JWT middleware validates the token signature
   and populates HttpContext.User (a ClaimsPrincipal)
        |
3. HttpContextClaimsPrincipalAccessor reads HttpContext.User
   and exposes it as IClaimsPrincipalAccessor
        |
4. CurrentUserService parses the claims into typed properties:
   - UserId   (string)  from NameIdentifier claim
   - AccountId (Guid)   from accountId claim
   - Email    (string)  from Email claim
   - IsAuthenticated (bool)
        |
5. Controller sends Command/Query via MediatR
        |
6. ValidationBehaviour runs FluentValidation rules
   (rejects invalid requests before the handler runs)
        |
7. Handler executes business logic using repositories
        |
8. DbContext applies Global Query Filters:
   WHERE AccountId = <authenticated user's AccountId>
   (automatic, cannot be bypassed by application code)
        |
9. Response flows back through the pipeline to the controller

Multi-Tenancy: How Data Isolation Works

Every entity that needs tenant isolation implements the IMultiTenant interface, which requires an AccountId property. This is used in two ways:

On read: EF Core Global Query Filters automatically append WHERE AccountId = @currentAccountId to every query. Developers do not need to add this manually -- it is impossible to forget.

On write: The SaveChangesAsync override checks that every new or modified IMultiTenant entity has the correct AccountId set. If an entity is inserted without an AccountId, it throws an exception rather than saving bad data.

graph LR
    subgraph "Account A"
        UA[User: Alice] --> AA[Account A]
        A1[Animal: Max] --> AA
        A2[Animal: Bella] --> AA
    end

    subgraph "Account B"
        UB[User: Bob] --> AB[Account B]
        B1[Animal: Rex] --> AB
    end

    style AA fill:#e8f5e9
    style AB fill:#e3f2fd

Alice can only see Max and Bella. Bob can only see Rex. The database enforces this boundary -- it is not dependent on application code remembering to filter.

Key Components

Component Layer Responsibility
ICurrentUserService Domain Interface exposing UserId, AccountId, Email, IsAuthenticated
IClaimsPrincipalAccessor Domain Abstraction over how the ClaimsPrincipal is accessed
CurrentUserService Infrastructure Parses JWT claims into typed properties
HttpContextClaimsPrincipalAccessor API Bridges ASP.NET Core's IHttpContextAccessor to IClaimsPrincipalAccessor
IMultiTenant Domain Interface marking entities that are scoped to an account
PetfolioDbContext Infrastructure Applies global query filters and sets AccountId on save
JWT Middleware API Validates token signatures and populates HttpContext.User

OAuth Providers

Petfolio stores which OAuth provider a user signed up with as a value object on the User entity:

Provider Status Enum Value
Google Active AuthenticationProvider.Google
Microsoft Planned AuthenticationProvider.Microsoft
Facebook Planned AuthenticationProvider.Facebook

Each user is linked to exactly one OAuth provider. A user who signs up with Google must always log in with Google. See ADR-001 for the rationale and trade-offs of this decision.

Security Summary

Concern Approach
Token validation Asymmetric keys (RSA/ECDSA) -- public key validates, private key signs
Token lifetime 15-minute access tokens, 7-day refresh tokens
Password storage None -- OAuth only
Data isolation EF Core Global Query Filters on AccountId
Rate limiting ASP.NET Core AddRateLimiter() on auth endpoints
Secrets User Secrets (dev), Azure Key Vault / AWS KMS (prod)

For the full security analysis, including identified risks and mitigations, see the Security section of ADR-001.