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¶
-
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
-
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)
-
Multi-Tenancy: Manual Global Query Filters (EF Core)
- Built-in EF Core feature - no external dependencies
- Automatic query filtering via
HasQueryFilter() - Manual
SaveChangesoverride to setAccountIdon 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
UserIdentityentity 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
IMultiTenantto 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
tokeninfoendpoint - 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
- Development: Use User Secrets (
- 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.Restricton 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.