Architecture

Architecture overview of Zooly authentication system

System Architecture

Zooly Auth is built on a centralized authentication model where all apps share a single identity provider.

flowchart TB
    subgraph client [Client Browser]
        LoginPage[Login Page]
        AuthContext[Auth Context]
    end

    subgraph guestClient [Guest Checkout Client]
        GuestCheckout[Guest Checkout]
    end
    
    subgraph authApp [auth.zooly.ai]
        LoginAPI["/api/auth/login"]
        MeAPI["/api/me"]
        GuestGetOrCreateAPI["/api/users/get-or-create"]
        LogoutAPI["/api/auth/logout"]
        CallbackAPI["/api/auth/callback"]
    end
    
    subgraph aws [AWS]
        Cognito[Cognito User Pool]
        DynamoDB[DynamoDB identities]
    end
    
    subgraph packages [Packages]
        AuthClient["@zooly/auth-client"]
        AuthSrv["@zooly/auth-srv"]
        AuthDB["@zooly/auth-db"]
    end
    
    LoginPage --> LoginAPI
    GuestCheckout --> GuestGetOrCreateAPI
    LoginAPI --> Cognito
    LoginAPI --> AuthDB
    GuestGetOrCreateAPI --> AuthDB
    AuthDB --> DynamoDB
    MeAPI --> AuthSrv
    AuthSrv --> Cognito
    AuthSrv --> AuthDB

Domain Topology

Apex Domain

  • zooly.ai - Production
  • dev.zooly.ai - Development

Central Auth App

  • auth.zooly.ai - Single entrypoint for all authentication

App Domains

Apps can be organized as:

  • zooly.ai/<app> or app1.zooly.ai, app2.zooly.ai
  • dev.zooly.ai/<app> or dev-app1.zooly.ai etc.

All apps under *.zooly.ai share the same authentication session.

Package Structure

The authentication system is organized into three main packages:

@zooly/auth-db

DynamoDB access layer for identity storage:

  • getIdentity(userId) - Get identity by user ID
  • upsertIdentity(identity) - Create or update identity
  • updateRoles(userId, roles) - Update user roles
  • findIdentityByEmail(email) - Find identity by email
  • getOrCreateByEmail(email, name?) - Get or create guest identity
  • linkCognitoIdentity(userId, cognitoSub) - Link Cognito user to existing identity

@zooly/auth-srv

Server-side authentication functions:

  • signIn(email, password) - Authenticate user
  • signUp(email, password, displayName?) - Register new user
  • confirmSignUp(email, code) - Verify email with code
  • resendConfirmationCode(email) - Resend verification code
  • forgotPassword(email) - Initiate password reset
  • confirmForgotPassword(email, code, newPassword) - Complete password reset
  • JWT verification using aws-jwt-verify (ID tokens)
  • Token refresh function (refreshTokens) using REFRESH_TOKEN_AUTH flow
  • Auto-refresh helper (verifyOrRefreshToken) that refreshes expired tokens automatically
  • Session/cookie helpers for ID token and refresh token storage

@zooly/auth-client

React components and utilities for authentication UI:

  • LoginPage - Main authentication page component
  • Form components: LoginForm, PasswordForm, SignUpForm, ConfirmSignUpForm, ForgotPasswordForm, ResetPasswordForm
  • AuthContextProvider and useAuth hook for user state management
  • validateReturnTo utility for safe redirect handling

Identity Storage Architecture

Identity storage is split between Cognito and DynamoDB:

Cognito
  └─ sub (identity anchor)
  └─ email (source of truth)

Identity Service
  └─ DynamoDB (sub → profile data)

Apps
  └─ call /api/me
       ├─ verify ID token JWT
       ├─ extract email from ID token (Cognito source of truth)
       ├─ read profile from DynamoDB using sub
       └─ return unified profile (email from Cognito + profile from DynamoDB)

Cognito Storage

  • sub: Stable user ID (identity anchor)
  • email: Source of truth for user email

DynamoDB Storage

Table: zooly-auth-identities

Primary Key: user_id (string) - Cognito sub for linked users, generated for email-only users

Item Structure:

{
  "user_id": "sub or generated",
  "guest_email": "guest@example.com",
  "display_name": "Elia",
  "avatar_url": "https://...",
  "roles": ["admin"],
  "cognito_sub": "sub",
  "created_at": 1700000000,
  "updated_at": 1700500000
}

Important: Email is NOT stored in DynamoDB. For Cognito-linked users, email comes from Cognito ID token (source of truth). Only guest_email is stored for guest checkout users before linking.

Session Management

  • Domain: .zooly.ai (shared across all subdomains)
  • Security Flags:
    • Secure always
    • HttpOnly where possible
    • SameSite=Lax by default

Token Strategy

  • ID Token Cookie (auth-token): Stores Cognito ID token in HttpOnly cookie
  • Refresh Token Cookie (auth-refresh-token): Stores Cognito refresh token in HttpOnly cookie
  • Backend Validation: Backends read and validate JWT directly using JWKS
  • Cookie Expiry: 90 days for both cookies
  • ID Token Validity: 24 hours (Cognito maximum)
  • Refresh Token Validity: 90 days
  • Automatic Refresh: When ID token expires, server automatically refreshes using refresh token (transparent to user)

Authorization Model

Identity Derivation

Backends derive identity from:

JWT Claims (from Cognito ID token):

  • sub - Stable user ID (used as DynamoDB key)
  • email - From Cognito ID token (source of truth, not stored in DynamoDB)

DynamoDB Profile (from identities table):

  • display_name
  • avatar_url
  • roles (string[] - stored in DynamoDB)
  • guest_email (only for guest checkout users before linking)

Roles

Roles are stored in DynamoDB and retrieved when looking up the identity:

  • Roles are stored as roles: string[] in the identities table
  • Default users have roles: [] (empty array)
  • Roles are managed via admin API or manual DynamoDB updates
  • Roles are returned in /api/me response from DynamoDB, not from Cognito token

Example roles:

  • ["admin"] => admin role
  • ["editor"] => editor role
  • [] => default user

Backend JWT Verification

Backends validate JWT signatures using JWKS (AWS verifier library):

  • Verify issuer (iss)
  • Verify token use (ID token - contains email from Cognito)
  • Verify expiration (exp)
  • Verify audience/client ID as appropriate
  • Authorize using roles from DynamoDB (looked up via sub)

No shared secret is used; keys are fetched and cached via JWKS.

Token Refresh Flow

When an ID token expires, the system automatically refreshes it:

  1. Token Verification: Backend attempts to verify ID token using JWKS
  2. Expiration Detection: If token is expired, system reads refresh token from cookie
  3. Refresh Request: Calls Cognito REFRESH_TOKEN_AUTH flow with refresh token
  4. New Token: Receives new ID token and Access token from Cognito
  5. Verification: Verifies new ID token using JWKS
  6. Cookie Update: Updates auth-token cookie with new ID token in response

Key Points:

  • Refresh happens transparently on the server (no redirects)
  • Only expired tokens trigger refresh (invalid/malformed tokens return 401 immediately)
  • Users stay logged in for the full 90-day refresh token lifetime
  • Refresh token remains valid for its full lifetime (not rotated)