Auth Module
Overview
The auth module is the security foundation of FlowPOS. It handles:
- Firebase ID token validation via a globally applied
AuthGuard - User registration / retrieval on first login
- Firebase custom claims synchronisation (
db_user_id,role) - Root (sudo) elevation protected by an Argon2-hashed server-side password
- Param decorators that inject auth context into any controller across the API
Architecture
auth/
├── auth.module.ts
├── application/
│ └── auth.service.ts ← use cases (verify, upsert, status, elevate)
├── domain/
│ └── auth.types.ts ← domain types (UserStatusResult, etc.)
├── infrastructure/
│ ├── auth.guard.ts ← global Firebase token guard
│ ├── auth-root.guard.ts ← root-password Argon2 guard
│ ├── auth.decorators.ts ← param decorators (@FirebaseUser, @UserId, …)
│ ├── auth.providers.ts ← HASHED_ROOT_PASSWORD injection token
│ └── auth.types.ts ← request/token type extensions
└── interfaces/
├── auth.controller.ts ← thin HTTP controller + Swagger
└── dtos/
├── create-or-get-user.dto.ts
├── auth-user-response.dto.ts
└── user-status-response.dto.ts
The module follows Hexagonal Architecture:
| Layer | Responsibility |
|---|---|
Domain (auth.types.ts) | Framework-agnostic types: UserStatusResult, UserStatusSuccess, UserStatusError |
Application (auth.service.ts) | Orchestrates use cases; depends only on domain types, FirebaseService, and UsersService |
| Infrastructure (guards, providers) | Implements transport-level security; framework-dependent |
| Interface (controller, DTOs) | HTTP mapping; thin; delegates entirely to AuthService |
Domain Concepts
Firebase custom claims
FlowPOS stores two pieces of information in Firebase custom claims:
| Claim | Type | Meaning |
|---|---|---|
db_user_id | string (UUID) | Links Firebase UID → FlowPOS DB user row |
role | AppRoleName | App-level role (Admin, Root, etc.) |
role_by_business_id | Record<string, string> | Business-scoped roles |
Claims are written by the backend after token verification and are available in the next token refresh.
Token sources
AuthGuard (and FirebaseIdToken decorator) extract the token from:
- Cookie
flowpos-id-token(set by the web app) Authorization: Bearer <token>header (used by the PWA and API clients)
Public routes
Any route decorated with @IsPublic() allows unauthenticated requests through. If a token is present it is still verified and request.firebaseUser is populated, enabling optional personalisation on public endpoints.
Use Cases (AuthService)
createOrGetUser(idToken, fullName?, email?)
- Verifies the Firebase ID token
- Upserts the user in the database (
getOrCreateUser) - Sets the
db_user_idcustom claim
Used by POST /auth/firebase — called on every first login or app launch.
getUserByToken(idToken)
- Verifies the Firebase ID token
- Looks up the existing DB user (no creation)
Used by POST /auth/firebase-get-user — for re-auth flows. Does not sync claims (avoids Firebase Admin API bottlenecks). Claims are synced on create and when roles change.
verifyToken(idToken)
Verifies the Firebase ID token and returns the decoded payload. Throws a mapped HttpException on any failure.
Used by POST /auth/validate.
getUserStatus(firebaseUid)
- Looks up the DB user by Firebase UID
- Fetches onboarding progress
- Returns a structured result (success or error payload, never throws)
Used by GET /auth/me. Returns UserStatusResult — a discriminated union so the controller stays thin.
elevateToRoot(uid, currentRole)
Sets role: Root custom claim. Throws 409 Conflict if the user is already Root.
Used by PATCH /auth/sudo-me (root-password gated).
Guards
AuthGuard (global)
Applied to all routes via APP_GUARD. Behaviour:
OPTIONSrequests: always allowed (CORS preflight)@IsPublic()routes: token verified if present, never blocked- Protected routes: valid token required;
401 Unauthorizedotherwise
AuthRootGuard
Applied individually with @UseGuards(AuthRootGuard). Reads rootPassword query parameter and verifies it with Argon2 against HASHED_ROOT_PASSWORD from environment config.
Decorators
| Decorator | Returns | Notes |
|---|---|---|
@IsPublic() | — | Route decorator; disables auth requirement |
@FirebaseUser() | ExtendedDecodedIdToken | undefined | Full decoded token incl. custom claims |
@FirebaseUid() | string | undefined | user_id or uid from the token |
@FirebaseIdToken() | string | undefined | Raw JWT string |
@UserId() | string | null | db_user_id claim, falling back to uid |
@MaybeUserId() | string | null | db_user_id claim only; null if not yet onboarded |
@IsRoot() | boolean | true when role === AppRoleName.Root |
API Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /auth/firebase | Public (Bearer) | Create or get user + sync claims |
POST | /auth/firebase-get-user | Public (Bearer) | Get existing user |
GET | /auth/me | Public (Bearer) | Current user status + onboarding |
POST | /auth/validate | Public (Bearer) | Token validity check |
PATCH | /auth/sudo-me | Bearer + rootPassword | Elevate to Root role |
Full Swagger documentation is available at GET /api when the app is running.
Example Requests
Login / Register
POST /auth/firebase
Authorization: Bearer <firebase-id-token>
Content-Type: application/json
{
"fullName": "Jane Doe",
"email": "jane@example.com"
}
Response 200:
{
"user": {
"id": "f04b2250-34a0-409c-bb57-612d863851c9",
"firebaseUid": "Z0KRm47UCuOrzqoxi6ZQGQg30Ia2",
"email": "jane@example.com",
"fullName": "Jane Doe"
}
}
Me / Onboarding Status
GET /auth/me
Authorization: Bearer <firebase-id-token>
Response 200:
{
"user": { "id": "f04b2250-...", "email": "jane@example.com" },
"step": "done",
"onboardingComplete": true
}
Sudo Elevation
PATCH /auth/sudo-me?rootPassword=<root-password>
Authorization: Bearer <firebase-id-token>
Error Responses
| Code | Cause |
|---|---|
400 | Token expired (auth/id-token-expired) |
400 | Token from wrong Firebase project (auth/argument-error audience mismatch) |
400 | Malformed token |
400 | rootPassword query param missing |
401 | No token provided |
401 | Token verification failed (non-Firebase errors) |
403 | Root password incorrect (/sudo-me) |
409 | User is already Root (/sudo-me) |
500 | DB error during user upsert or Firebase claims update |
Environment Variables
| Variable | Required | Description |
|---|---|---|
HASHED_ROOT_PASSWORD | Yes | Argon2 hash of the sudo password (with encryption key as pepper) |
ENCRYPTION_KEY | Yes | 32-byte hex key used as Argon2 pepper |
FIREBASE_PROJECT_ID | Yes | Firebase project ID for token validation |
FIREBASE_CLIENT_EMAIL | Yes | Firebase Admin SDK service account email |
FIREBASE_PRIVATE_KEY | Yes | Firebase Admin SDK private key |
Design Decisions
Why db_user_id in custom claims?
Firebase tokens are short-lived JWTs that do not carry DB primary keys. By writing db_user_id into custom claims after registration, every subsequent authenticated request has the DB user ID available without a database round-trip. Claims are set during POST /auth/firebase and when roles change; POST /auth/firebase-get-user does not sync claims to avoid Firebase Admin API bottlenecks.
Why Argon2 for the root password?
Argon2 is the winner of the Password Hashing Competition and resistant to GPU/ASIC attacks. The encryption key is used as a pepper (secret known only to the server) in addition to the salt, making offline dictionary attacks infeasible even with the hash.
Why does GET /auth/me return an error payload instead of an HTTP error?
The endpoint is intentionally @IsPublic() so the frontend can call it unconditionally on app load. Returning a structured payload with onboardingComplete: false and an error field avoids error-handling boilerplate on the client side for unauthenticated states.