Saltar a contenido

Auth Flow

The CyberEco Hub uses a hybrid authentication model: client-side Firebase Authentication for credential verification, combined with an httpOnly cookie for server-side route protection.

Overview

sequenceDiagram
    participant U as User (Browser)
    participant R as React Island
    participant FA as Firebase Auth
    participant API as Hub API Server
    participant MW as Astro Middleware

    Note over U,MW: Step 1: User submits credentials
    U->>R: Enter email + password
    R->>FA: signInWithEmailAndPassword(email, password)
    FA-->>R: UserCredential { user.uid }

    Note over U,MW: Step 2: Set server-side cookie
    R->>API: POST /api/auth/set-cookie { uid }
    API-->>API: Set httpOnly cookie (cybereco-auth-token = uid)
    API-->>R: { success: true }

    Note over U,MW: Step 3: Navigate to protected route
    R->>U: window.location.href = '/dashboard'
    U->>MW: GET /dashboard (cookie attached)
    MW-->>MW: Read cybereco-auth-token cookie
    MW-->>U: 200 OK (render dashboard page)

    Note over U,MW: Step 4: Background sync (onAuthStateChanged)
    FA-->>R: onAuthStateChanged(user)
    R->>API: POST /api/auth/set-cookie { uid }
    API-->>R: { success: true }

    Note over U,MW: Step 5: Cookie refresh (every 45 min)
    R->>API: POST /api/auth/refresh
    API-->>API: Reset cookie Max-Age to 3600s
    API-->>R: { success: true }

    Note over U,MW: Step 6: Sign out
    R->>FA: signOut()
    R->>API: DELETE /api/auth/refresh
    API-->>API: Set cookie Max-Age to 0
    API-->>R: { success: true }
    R->>U: window.location.href = '/auth/signin'

Step-by-Step Walkthrough

Step 1: Credential Submission

The user enters their email and password into the SignInForm React island. The form calls Firebase Auth directly from the client:

import { getAuth, signInWithEmailAndPassword } from 'firebase/auth';

const auth = getAuth(firebaseApp);
const credential = await signInWithEmailAndPassword(auth, email, password);

Firebase Auth validates the credentials against the Firebase project and returns a UserCredential containing the user's UID, email, display name, and other profile data.

Why client-side Firebase Auth?

Firebase Auth manages password hashing, rate limiting, email verification, and OAuth flows. The Hub delegates all credential management to Firebase and only needs the resulting UID for session tracking.

After successful Firebase authentication, the form calls the Hub's POST /api/auth/set-cookie endpoint with the Firebase UID:

await fetch('/api/auth/set-cookie', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ uid: user.uid }),
});

The API route sets an httpOnly cookie:

cookies.set('cybereco-auth-token', uid, {
  path: '/',
  maxAge: 3600,       // 1 hour
  sameSite: 'lax',
  httpOnly: true,     // Not accessible via JavaScript (XSS protection)
  secure: !isDev,     // HTTPS only in production
});

Step 3: Protected Route Access

After the cookie is set, the form performs a full-page navigation:

window.location.href = '/dashboard';

Full-page navigation, not SPA routing

The Hub uses window.location.href (not React Router or Astro's navigate()) to ensure the browser sends the newly-set cookie with the request. SPA-style navigation would not trigger a new server request, and the middleware would not see the cookie.

The Astro middleware intercepts the request, reads the cybereco-auth-token cookie, and either allows access or redirects to the sign-in page:

const authToken = cookies.get('cybereco-auth-token');
if (!authToken?.value) {
  const returnUrl = encodeURIComponent(pathname);
  return redirect(`/auth/signin?returnUrl=${returnUrl}`, 302);
}

Step 4: Background Sync

The HubAuthProvider registers a Firebase onAuthStateChanged listener. When Firebase detects the user is authenticated (including on page reload), it calls POST /api/auth/set-cookie to ensure the server-side cookie stays in sync:

onAuthStateChanged(auth, async (user) => {
  if (user) {
    await fetch('/api/auth/set-cookie', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ uid: user.uid }),
    });
  }
});

This handles edge cases where the cookie expires but the Firebase Auth session is still valid (Firebase sessions persist in IndexedDB).

The cookie has a 1-hour Max-Age. To prevent session expiry during active use, the client calls POST /api/auth/refresh every 45 minutes:

// In HubAuthProvider
const REFRESH_INTERVAL = 45 * 60 * 1000; // 45 minutes

useEffect(() => {
  const interval = setInterval(() => {
    fetch('/api/auth/refresh', { method: 'POST' });
  }, REFRESH_INTERVAL);
  return () => clearInterval(interval);
}, []);

The refresh endpoint resets the cookie's Max-Age to 3600 seconds without changing its value.

Step 6: Sign Out

Sign-out involves two steps:

  1. Firebase sign-out -- clears the client-side Firebase Auth session
  2. Cookie clearing -- DELETE /api/auth/refresh sets the cookie's Max-Age to 0
const auth = getAuth(firebaseApp);
await signOut(auth);
await fetch('/api/auth/refresh', { method: 'DELETE' });
window.location.href = '/auth/signin';
Property Value Purpose
Name cybereco-auth-token Identifies the cookie
Value Firebase UID (string) Identifies the authenticated user
Max-Age 3600 (1 hour) Cookie expiry; refreshed every 45 min
Path / Available on all routes
SameSite Lax Sent on same-site requests and top-level navigations
HttpOnly true Not accessible via document.cookie (XSS protection)
Secure true in production Only sent over HTTPS

Protected Routes

The middleware checks the auth cookie for these route prefixes:

Prefix Pages
/dashboard Main dashboard
/apps Ecosystem app directory
/my-data Personal data management
/profile User profile
/privacy Privacy settings
/settings Account settings
/billing Subscription and billing
/security Security settings (password, 2FA, sessions, devices)
/admin Admin panel

These routes are always public (no cookie required):

Prefix Pages
/auth Sign-in, sign-up, password reset
/api All API routes (they check auth internally)
/coming-soon Placeholder pages

SSO Token Generation

For cross-app navigation (Hub to JustSplit, Somos, etc.), the Hub generates HMAC-SHA256 signed tokens via POST /api/auth/generate-token:

sequenceDiagram
    participant U as User
    participant Hub as Hub App
    participant API as Hub API
    participant App as Ecosystem App

    U->>Hub: Click "Open JustSplit"
    Hub->>API: POST /api/auth/generate-token { appId, appUrl }
    API-->>API: Validate appUrl against allowlist
    API-->>API: Sign { appId, timestamp, exp } with HMAC-SHA256
    API-->>Hub: { url, token }
    Hub->>U: Redirect to url (includes ?authToken=...)
    U->>App: GET /dashboard?authToken=...&fromHub=true&appId=justsplit
    App-->>App: Verify HMAC signature, check exp
    App-->>U: 200 OK (authenticated session)

Token properties:

  • Signing algorithm: HMAC-SHA256 (Web Crypto API)
  • Format: base64url(payload).base64url(signature)
  • Expiry: 5 minutes from generation
  • Payload: { appId, timestamp, exp }
  • Secret: SSO_TOKEN_SECRET environment variable

Allowed origins:

  • https://justsplit.cybere.co
  • https://somos.cybere.co
  • https://demos.cybere.co
  • https://plantopia.cybere.co
  • https://cybere.co
  • Development: http://localhost:40000-40002

Security Considerations

Important Security Notes

  • The cybereco-auth-token cookie contains the Firebase UID, not a Firebase ID token. This is a deliberate simplification for the current implementation. In production, you should consider storing a server-signed session token instead.
  • The httpOnly flag prevents JavaScript access, protecting against XSS-based cookie theft.
  • The Secure flag (production only) ensures the cookie is never sent over plain HTTP.
  • CSRF protection is handled separately by the middleware via the double-submit cookie pattern. See Middleware for details.