Skip to content

Cloud Functions Authorization - Phase 2 Implementation

Status: ✅ Implemented (January 31, 2026)
Purpose: Implement backend authorization handlers for onboarding & tenant selection
Reference: AUTHORIZATION_ARCHITECTURE.md


Overview

Three new Cloud Functions implement the server-side authentication flow:

  1. Updated onAuthCreate - Create new users with END_USER role (Firestore, not Auth claims)
  2. New completeOnboarding - Validate tenant & member number, finalize onboarding
  3. New getTenantsList - Return available clubs for dropdown selection

Architecture Change

Before: Role stored in Auth custom claims (hybrid model)

// ❌ Stale claims risk
await auth.setCustomUserClaims(uid, {
  role: "ADMIN",        // Can become stale
  tenant_id: "73"
});

After: Role stored in Firestore (single source of truth)

// ✅ Always fresh, read by rules
await firestore.collection("users").doc(uid).set({
  role: "END_USER",     // In Firestore
  tenant_id: "73"       // Set by completeOnboarding()
});

await auth.setCustomUserClaims(uid, {
  tenant_id: "73"       // Minimal claims
});

1. Updated Handler: onAuthCreate

File: functions/src/handlers/onAuthCreate.ts

Trigger: User signs up (Google, Email/Password, etc.)

What it does:

  1. Creates /users/{uid} document with:
  2. role: "END_USER" (always for new users)
  3. tenant_id: null (not set yet)
  4. memberID: null (not set yet)
  5. onboardingCompleted: false (blocks database access)
  6. source: "google" | "email" (login method)

  7. Sets Auth custom claims:

  8. tenant_id: null (will be set by completeOnboarding)
  9. NOT setting role (it's in Firestore)

  10. Logs signup event

Document created:

/users/alice123 {
  uid: "alice123"
  email: "alice@example.com"
  displayName: "Alice"
  photoURL: "https://..."

  role: "END_USER"
  tenant_id: null
  memberID: null
  user_wallet_id: null
  source: "google"

  onboardingCompleted: false
  onboardingStartedAt: timestamp

  createdAt: timestamp
  updatedAt: timestamp
  isBlocked: false
}

Why this matters: - ✅ Single source of truth: role is in Firestore, not Auth claims - ✅ Firestore rules can immediately enforce: user cannot access /tenants/ until onboarded - ✅ No delay waiting for Auth claims propagation - ✅ User sees OnboardingModal immediately after signup


2. New Handler: completeOnboarding

File: functions/src/handlers/completeOnboarding.ts

Trigger: Frontend calls complete_onboarding({tenantId: "73", memberNumber: "524"})

Type: Callable Cloud Function

Flow:

User selects tenant + member number
        ↓
Frontend calls completeOnboarding()
        ↓
STEP 1: Verify ID token valid
        ↓
STEP 2: Validate tenant "73" exists in Firestore
        ├─ If NOT: Return 404 "Tenant not found"
        ↓
STEP 3: Validate member number "524" format
        ├─ Must be 1-7 digits only
        ├─ Pad to 7: "524" → "000524"
        ├─ If invalid: Return 400 "Invalid format"
        ↓
STEP 4: Check "000524" not already used in tenant 73
        ├─ Query: WHERE tenant_id="73" AND memberID="000524"
        ├─ If found: Return 409 "Already in use"
        ↓
STEP 5: Calculate user_wallet_id = "73-000524"
        ↓
STEP 6: Update /users/{uid} document:
        ├─ tenant_id: "73"
        ├─ memberID: "000524"
        ├─ user_wallet_id: "73-000524"
        ├─ onboardingCompleted: true
        └─ onboardingCompletedAt: timestamp
        ↓
STEP 7: Set Auth custom claims: {tenant_id: "73"}
        ↓
STEP 8: Return success + user object
        ↓
Frontend modal closes
User redirected to /wallet

Input

{
  tenantId: string;      // Club ID (e.g., "73")
  memberNumber: string;  // 1-7 digits (e.g., "524")
}

Output

{
  success: true,
  user: {
    uid: "alice123",
    email: "alice@example.com",
    role: "END_USER",
    tenant_id: "73",
    memberID: "000524",
    user_wallet_id: "73-000524",
    onboardingCompleted: true
  }
}

Error Handling

Error Status Message
Not authenticated 401 "User must be authenticated"
Tenant not found 404 "Tenant {id} does not exist"
Invalid member number 400 "Member number must be 1-7 digits only"
Member number in use 409 "Member number {id} already in use"
Database error 500 "Failed to complete onboarding"

Security Properties

  • ✅ Only authenticated users can call
  • ✅ User can only set own tenant_id (not others')
  • ✅ Tenant must exist (prevents typos)
  • ✅ Member number validated client + server
  • ✅ Duplicate prevention per tenant
  • ✅ Logged for audit trail

3. New Utility: onboardingUtils.ts

File: functions/src/utils/onboardingUtils.ts

Helper functions used by Cloud Functions:

padMemberNumber(input: string)

Converts variable-length member numbers to 7-digit format.

Examples:

"1"       → "000001"
"123"     → "000123"
"1234567" → "1234567"
"abc"     → Error
"12345678"→ Error (too long)

Usage:

const result = padMemberNumber("524");
if (result.valid) {
  const memberID = result.padded;  // "000524"
} else {
  const error = result.error;  // "Member number must be 1-7 digits only"
}

formatUserWalletId(tenantId, memberID)

Creates the combined login identifier.

Examples:

("73", "000524")   → "73-000524"
("9999", "000001") → "9999-000001"

Usage:

const walletId = formatUserWalletId("73", "000524");
// walletId = "73-000524"

tenantExists(tenantId)

Checks if tenant document exists in Firestore.

Usage:

const exists = await tenantExists("73");
if (!exists) throw new Error("Tenant not found");

memberNumberInUse(tenantId, memberID)

Checks if member number already assigned in this tenant.

Usage:

const inUse = await memberNumberInUse("73", "000524");
if (inUse) throw new Error("Member number already in use");

getActiveTenants()

Fetches all active clubs for dropdown.

Returns:

[
  {
    id: "73",
    name: "Ski golfklubb",
    logo: "https://...",
    memberCount: 250
  },
  {
    id: "9999",
    name: "Golfklubb-IT",
    logo: "https://...",
    memberCount: 45
  }
]

Usage:

const tenants = await getActiveTenants();
// Use in getTenantsList() response

4. New Handler: getTenantsList

File: functions/src/handlers/getTenantsList.ts

Trigger: Frontend GET /api/tenants-list

Type: HTTP Cloud Function (REST endpoint)

Purpose: Populate onboarding dropdown with available clubs

Response:

{
  "success": true,
  "tenants": [
    {
      "id": "9999",
      "name": "Golfklubb-IT",
      "logo": "https://logo.png",
      "memberCount": 45
    },
    {
      "id": "73",
      "name": "Ski golfklubb",
      "logo": "https://logo.png",
      "memberCount": 250
    }
  ]
}

Features

  • Public endpoint - No auth required (needed for new users)
  • Cached - 5-minute cache header (browser/CDN)
  • Sorted - By club name alphabetically
  • Filtered - Only active: true clubs

Security

  • No sensitive data exposed
  • Only public club information
  • No member lists or private data

Integration: Complete Flow

User Signs Up (Google)

1. User clicks "Sign in with Google"
   
2. Google auth completes
   
3. Firebase creates user in Auth
   
4. onAuthCreate triggered
   ├─ Creates /users/{uid} with:
     ├─ role: "END_USER"
     ├─ tenant_id: null
     ├─ onboardingCompleted: false
   └─ Sets Auth claims: {tenant_id: null}
   
5. Frontend receives auth success
   
6. LoginPage checks: onboardingCompleted?
   ├─ No  Show OnboardingModal
   └─ Yes  Redirect to /wallet
   
7. OnboardingModal renders
   ├─ Calls getTenantsList()  shows dropdown
   ├─ User selects "73 - Ski golfklubb"
   ├─ User enters member number "524"
   ├─ User clicks "Fullfør"
   
8. Frontend calls completeOnboarding({tenantId: "73", memberNumber: "524"})
   
9. completeOnboarding validates:
   ├─ Tenant 73 exists? 
   ├─ Member number "524" valid? 
   ├─ "000524" unique in tenant 73? 
   
10. Update /users/{uid}:
    ├─ tenant_id: "73"
    ├─ memberID: "000524"
    ├─ user_wallet_id: "73-000524"
    ├─ onboardingCompleted: true
    
11. Set Auth claims: {tenant_id: "73"}
    
12. Return success
    
13. Frontend modal closes
    
14. Firestore rules now allow:
    ├─ Read /tenants/73/wallet_items
    ├─ Read /tenants/73/ledger_transactions
    ├─ User redirected to /wallet
    └─  Access granted!

Testing

Synthetic Test Scenario

Setup:

/tenants/73 {
  name: "Ski golfklubb"
  active: true
}

/tenants/9999 {
  name: "Golfklubb-IT"
  active: true
}

Test Case 1: New user onboards successfully

1. User alice@example.com signs up (Google)
2. onAuthCreate creates /users/alice123 with:
   ├─ role: "END_USER"
   ├─ tenant_id: null
   └─ onboardingCompleted: false
3. LoginPage shows OnboardingModal
4. Frontend fetches tenants (getTenantsList):
    Returns: [{id: "73", name: "Ski golfklubb"}, {id: "9999", ...}]
5. User selects "73" and enters "524"
6. Frontend calls completeOnboarding({tenantId: "73", memberNumber: "524"})
7. completeOnboarding succeeds:
   ├─ Validates tenant 73 exists 
   ├─ Validates member number "524" 
   ├─ Checks "000524" not used 
   ├─ Updates /users/alice123:
     ├─ tenant_id: "73"
     ├─ memberID: "000524"
     ├─ user_wallet_id: "73-000524"
     ├─ onboardingCompleted: true
   └─ Returns success
8. Frontend modal closes
9. User redirected to /wallet 

Test Case 2: Duplicate member number prevention

Setup: User bob@example.com with memberID "000524" in tenant 73 exists

1. New user charlie@example.com tries to onboard
2. Enters tenant "73" and member number "524"
3. completeOnboarding fails:
   ├─ Tenant 73 exists 
   ├─ Member number valid 
   ├─ Checks: is "000524" used in tenant 73?
   ├─ Query finds bob's document  (found)
   ├─ Return error: 409 "Member number 000524 already in use"
   └─ User cannot complete onboarding 

Deployment

Functions to Deploy

firebase deploy --only functions:setup_user_claims
firebase deploy --only functions:complete_onboarding
firebase deploy --only functions:get_tenants_list

Firestore Indexes Required

None - existing users collection used

Database Rules Impact

Phase 1 Firestore rules already handle: - ✅ Blocking access if onboardingCompleted == false - ✅ Enforcing tenant isolation after onboarding - ✅ Reading role from /users/{uid} (not Auth claims)


Performance

Operation Reads Writes Latency
getTenantsList 1 query 0 ~100ms (cached)
completeOnboarding 2 queries 1 update ~500ms
onAuthCreate 0 1 create + 1 claim ~200ms

Caching: - getTenantsList: 5-minute HTTP cache - Reduces Firestore reads on subsequent onboardings


Next Phase

Phase 3: Frontend Implementation - Implement OnboardingModal component - Implement useUserRole() hook - Implement ProtectedRoute component - Update LoginPage with role-based routing


Status: ✅ Ready for deployment to Firebase Console

Last Updated: January 31, 2026