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:
- Updated
onAuthCreate- Create new users with END_USER role (Firestore, not Auth claims) - New
completeOnboarding- Validate tenant & member number, finalize onboarding - 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:
- Creates
/users/{uid}document with: role: "END_USER"(always for new users)tenant_id: null(not set yet)memberID: null(not set yet)onboardingCompleted: false(blocks database access)-
source: "google" | "email"(login method) -
Sets Auth custom claims:
tenant_id: null(will be set by completeOnboarding)-
NOT setting role (it's in Firestore)
-
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: trueclubs
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