Authentication Flows

Complete authentication flows including email/password, social login, session management, and refresh tokens

Overview

Zooly Auth provides multiple authentication flows for different use cases:

  • Redirect-based login: Standard flow for web apps using returnTo parameter
  • Email/Password: Traditional authentication with email verification
  • Social Login: Google and Apple OAuth (Phase 2)
  • Guest Checkout: Email-only identity for purchases without registration
  • Mini-App Login: Inline authentication without redirects

All flows result in the same session cookies shared across all *.zooly.ai subdomains.


Redirect + returnTo Flow

The standard authentication flow uses redirects with a returnTo parameter to bring users back to their original destination after login.

Entry Flow

Each app redirects unauthenticated users to:

https://auth.zooly.ai?returnTo=https://zooly.ai/my-app

returnTo Validation

To prevent open redirects, returnTo URLs are validated:

Server-side validation:

  • Validates URLs in middleware (implementation-specific)
  • Allows same-origin URLs or relative paths
  • Prevents javascript: and data: URLs

Client-side validation:

  • Allows same-origin URLs or relative paths
  • Prevents javascript: and data: URLs

Note: For local development, validation is relaxed to allow localhost and relative paths.

Post-Login Flow

After successful authentication:

  1. Auth app sets session cookies for .zooly.ai (both ID token and refresh token)
  2. Auth app redirects the user to the validated returnTo URL

Example Flow

1. User visits: https://app.zooly.ai/dashboard
2. App detects no session → Redirects to: https://auth.zooly.ai?returnTo=https://app.zooly.ai/dashboard
3. User logs in at auth.zooly.ai
4. Auth app sets cookies for .zooly.ai (auth-token and auth-refresh-token)
5. Auth app redirects to: https://app.zooly.ai/dashboard
6. User arrives back at dashboard with valid session

Email/Password Authentication Flow

Sign Up Flow

sequenceDiagram
    participant User
    participant LoginPage
    participant AuthAPI
    participant Cognito
    participant DynamoDB

    User->>LoginPage: Enters email
    LoginPage->>LoginPage: Shows SignUpForm
    User->>LoginPage: Enters password + display name
    LoginPage->>AuthAPI: POST /api/auth/signup
    AuthAPI->>Cognito: signUp(email, password)
    Cognito-->>AuthAPI: User created (unconfirmed)
    AuthAPI->>DynamoDB: upsertIdentity (if new)
    AuthAPI->>DynamoDB: linkCognitoIdentity (if guest exists)
    Cognito->>User: Sends verification code via email
    AuthAPI-->>LoginPage: Success
    LoginPage->>LoginPage: Shows ConfirmSignUpForm
    User->>LoginPage: Enters verification code
    LoginPage->>AuthAPI: POST /api/auth/confirm
    AuthAPI->>Cognito: confirmSignUp(email, code)
    Cognito-->>AuthAPI: User confirmed
    AuthAPI->>AuthAPI: signIn(email, password)
    Cognito-->>AuthAPI: idToken + refreshToken
    AuthAPI-->>LoginPage: Sets cookies + redirects to returnTo

Steps:

  1. User enters email → LoginForm
  2. User clicks "Sign up" → SignUpForm
  3. User enters password and optional display name
  4. POST /api/auth/signup
    • Creates Cognito user (unconfirmed)
    • Creates/updates DynamoDB identity
    • Links guest identity if email matches (linkCognitoIdentity)
  5. Cognito sends verification code to email
  6. User sees ConfirmSignUpForm
  7. User enters verification code
  8. POST /api/auth/confirm
    • Confirms Cognito user
    • Automatically signs in user
    • Sets both session cookies (auth-token and auth-refresh-token)
    • Redirects to returnTo

Login Flow

sequenceDiagram
    participant User
    participant LoginPage
    participant AuthAPI
    participant Cognito
    participant DynamoDB

    User->>LoginPage: Enters email
    LoginPage->>LoginPage: Shows PasswordForm
    User->>LoginPage: Enters password
    LoginPage->>AuthAPI: POST /api/auth/login
    AuthAPI->>Cognito: signIn(email, password)
    Cognito-->>AuthAPI: idToken + refreshToken
    AuthAPI->>AuthAPI: verifyToken(idToken)
    AuthAPI->>DynamoDB: getIdentity(sub)
    AuthAPI->>DynamoDB: linkCognitoIdentity (if guest exists)
    AuthAPI-->>LoginPage: Sets cookies + redirects to returnTo

Steps:

  1. User enters email → LoginForm
  2. User clicks "Continue" → PasswordForm
  3. User enters password
  4. POST /api/auth/login
    • Authenticates with Cognito (USER_PASSWORD_AUTH flow)
    • Verifies ID token
    • Links guest identity if email matches (linkCognitoIdentity)
    • Sets both session cookies (auth-token and auth-refresh-token)
    • Redirects to returnTo

Forgot Password Flow

sequenceDiagram
    participant User
    participant LoginPage
    participant AuthAPI
    participant Cognito

    User->>LoginPage: Clicks "Forgot password"
    LoginPage->>LoginPage: Shows ForgotPasswordForm
    User->>LoginPage: Enters email
    LoginPage->>AuthAPI: POST /api/auth/forgot-password
    AuthAPI->>Cognito: forgotPassword(email)
    Cognito->>User: Sends reset code via email
    AuthAPI-->>LoginPage: Success
    LoginPage->>LoginPage: Shows ResetPasswordForm
    User->>LoginPage: Enters code + new password
    LoginPage->>AuthAPI: POST /api/auth/reset-password
    AuthAPI->>Cognito: confirmForgotPassword(email, code, newPassword)
    Cognito-->>AuthAPI: Password reset successful
    AuthAPI-->>LoginPage: Success - user can now login

Steps:

  1. User clicks "Forgot password" → ForgotPasswordForm
  2. User enters email
  3. POST /api/auth/forgot-password
    • Sends reset code to email via Cognito
  4. User sees ResetPasswordForm
  5. User enters code and new password
  6. POST /api/auth/reset-password
    • Resets password in Cognito
    • User can now login with new password

Social Login Flow (Google/Apple)

OAuth Flow

sequenceDiagram
    participant User
    participant AuthApp
    participant Cognito
    participant Google/Apple
    participant AuthAPI

    User->>AuthApp: Clicks "Continue with Google"
    AuthApp->>AuthAPI: GET /api/auth/social/google?returnTo=...
    AuthAPI->>Cognito: Generate hosted UI URL
    AuthAPI-->>AuthApp: Redirect to Cognito hosted UI
    AuthApp->>Cognito: User authenticates with Google
    Cognito->>Google/Apple: OAuth flow
    Google/Apple-->>Cognito: Authorization code
    Cognito-->>AuthApp: Redirect to /api/auth/callback?code=...
    AuthApp->>AuthAPI: GET /api/auth/callback?code=...
    AuthAPI->>Cognito: exchangeCodeForTokens(code)
    Cognito-->>AuthAPI: idToken + refreshToken
    AuthAPI->>AuthAPI: verifyToken(idToken)
    AuthAPI->>DynamoDB: upsertIdentity / linkCognitoIdentity
    AuthAPI-->>AuthApp: Sets cookies + redirects to returnTo

Steps:

  1. User clicks "Continue with Google/Apple" button
  2. GET /api/auth/social/[provider]?returnTo=...
    • Generates Cognito hosted UI URL with OAuth parameters
    • Stores returnTo in cookie for callback
    • Redirects user to Cognito hosted UI
  3. User authenticates with Google/Apple at Cognito
  4. Cognito redirects to /api/auth/callback?code=...
  5. GET /api/auth/callback
    • Exchanges authorization code for tokens (exchangeCodeForTokens)
    • Verifies ID token
    • Creates/updates DynamoDB identity
    • Links guest identity if email matches
    • Sets both session cookies (auth-token and auth-refresh-token)
    • Redirects to returnTo

Note: Social login is currently implemented and working. The flow uses the same session cookies as email/password authentication.


Guest User Linking

When a user registers or logs in with an email that was previously used for a guest checkout:

sequenceDiagram
    participant User
    participant AuthAPI
    participant Cognito
    participant DynamoDB

    User->>AuthAPI: Login/Signup with email
    AuthAPI->>Cognito: Authenticate
    Cognito-->>AuthAPI: idToken (contains email)
    AuthAPI->>DynamoDB: findIdentityByEmail(email)
    DynamoDB-->>AuthAPI: Guest identity found
    AuthAPI->>DynamoDB: linkCognitoIdentity(user_id, cognito_sub)
    Note over DynamoDB: Sets cognito_sub, preserves user_id
    AuthAPI-->>User: Session created (linked identity)

Process:

  1. System checks for existing identity with matching guest_email
  2. If found, links Cognito identity to existing user_id using linkCognitoIdentity
  3. Sets cognito_sub on existing identity
  4. Preserves all purchase history and data (same user_id)

This enables:

  • Guest purchase now
  • Optional registration later
  • Full purchase history visible after login

Session Management

Session Creation

Sessions are created when:

  • User successfully logs in (POST /api/auth/login)
  • User completes signup and email verification (POST /api/auth/confirm)
  • User completes social login OAuth callback (GET /api/auth/callback)

Session Cookies

Two cookies are set for each session:

auth-token (ID Token)

  • Content: Cognito ID token (JWT)
  • Domain: .zooly.ai (shared across all subdomains)
  • Max-Age: 90 days (~3 months)
  • HttpOnly: true (not accessible to JavaScript)
  • Secure: true (HTTPS only in production)
  • SameSite: Lax
  • Token Validity: 24 hours (Cognito maximum)

auth-refresh-token (Refresh Token)

  • Content: Cognito refresh token (not a JWT)
  • Domain: .zooly.ai (shared across all subdomains)
  • Max-Age: 90 days (~3 months)
  • HttpOnly: true (not accessible to JavaScript)
  • Secure: true (HTTPS only in production)
  • SameSite: Lax
  • Token Validity: 90 days

Important: Both cookies are HttpOnly and only accessible server-side. The refresh token is never exposed to client-side JavaScript.

Automatic Token Refresh

When the ID token expires (after 24 hours), the system automatically refreshes it using the refresh token:

sequenceDiagram
    participant Browser
    participant AuthAPI
    participant Cognito

    Browser->>AuthAPI: GET /api/me (with cookies)
    AuthAPI->>AuthAPI: verifyToken(idToken)
    Note over AuthAPI: Token expired
    AuthAPI->>AuthAPI: Read auth-refresh-token cookie
    AuthAPI->>Cognito: REFRESH_TOKEN_AUTH(refreshToken)
    Cognito-->>AuthAPI: new idToken + accessToken
    AuthAPI->>AuthAPI: verifyToken(newIdToken)
    AuthAPI-->>Browser: User profile + Set-Cookie: auth-token=newIdToken
    Note over Browser: Cookie updated automatically

How it works:

  1. User makes request to /api/me (or any protected endpoint)
  2. Server attempts to verify ID token
  3. If token is expired:
    • Server reads auth-refresh-token cookie
    • Calls Cognito REFRESH_TOKEN_AUTH flow
    • Receives new ID token and Access token
    • Verifies new ID token
    • Updates auth-token cookie in response
  4. User continues seamlessly (no redirect or re-authentication)

Key points:

  • Refresh happens transparently on the server
  • No redirects or user interaction required
  • Users stay logged in for the full 90-day refresh token lifetime
  • Refresh only occurs when token is expired (not on invalid/malformed tokens)

Session Verification

Apps verify sessions by:

  1. Reading ID token from auth-token cookie
  2. Validating JWT signature using JWKS (AWS verifier library)
  3. Checking expiration (auto-refreshes if expired)
  4. Extracting user info (sub, email) from token claims
  5. Looking up user profile and roles from DynamoDB

JWT Verification:

  • Uses aws-jwt-verify library with JWKS
  • Verifies issuer (iss), audience (aud), expiration (exp)
  • Validates token signature against Cognito public keys
  • No shared secrets required

Token Verification and Refresh Logic

The verifyOrRefreshToken() helper function implements the refresh flow:

  1. First attempt: Verify the ID token using JWKS
  2. On expiration: If token is expired and a refresh token is available, automatically refresh the ID token using REFRESH_TOKEN_AUTH flow
  3. On invalid token: If token is malformed or invalid (not just expired), return 401 immediately without attempting refresh
  4. Cookie update: If refresh succeeds, update the auth-token cookie with the new ID token in the response

This ensures:

  • Expired tokens are automatically refreshed (transparent to user)
  • Invalid tokens are rejected immediately (security best practice)
  • Refresh only happens when appropriate (not on malformed tokens)

Mini-App Login Support

Mini-apps need a fast, in-page login experience without sending the user away from the app. We support this with a hybrid approach, using the same Cognito setup and the same session as the platform.

Shared Foundations

Mini-apps use exactly the same identity system:

  • Same Cognito User Pool
  • Same Cognito app client
  • Same roles stored in DynamoDB
  • Same session cookie (Domain=.zooly.ai)
  • Same expiry settings

From the backend's point of view, a user logged in from a mini-app is indistinguishable from one logged in from the platform.

Password Login (Inline)

For email/password authentication:

  • Mini-app renders a login form inside the page
  • Credentials are submitted (directly or via backend) to Cognito
  • On success:
    • Session cookie is created for .zooly.ai
    • User becomes logged in across all apps
    • No page navigation is required

This gives the "quick login" experience without redirects.

Social Login (Seamless)

For Google / Apple:

  • Mini-app shows "Continue with Google / Apple" buttons
  • Clicking them triggers the same Cognito social flow
  • Auth completes and the session cookie is set
  • Mini-app resumes in place, now authenticated

From the user's perspective:

  • Login happens within the mini-app
  • No visible redirect to another app
  • No loss of state

From the system's perspective:

  • This is the same auth flow used everywhere else

Resulting Behavior

After login from a mini-app:

  • User is authenticated in:
    • the mini-app
    • the main platform
    • any other *.zooly.ai app
  • Roles (from DynamoDB) are immediately available
  • Session expiry and renewal behave exactly the same

Logout

Local Logout

Logout clears both session cookies:

POST /api/auth/logout

This removes both auth-token and auth-refresh-token cookies from the current browser. The cookies are cleared by setting them to expire immediately (Max-Age=0).

Note: This removes the session from the current browser but does not invalidate tokens server-side. Tokens remain valid until they expire naturally (24 hours for ID tokens, 90 days for refresh tokens).

Global Sign-Out

Global sign-out (invalidating tokens server-side via Cognito) is not required for the current security posture but can be implemented if needed.


Testing Refresh Token Flow

The refresh token implementation includes test endpoints for validation:

/api/test/refresh-flow

Reads both cookies and attempts to verify/refresh the ID token:

fetch('/api/test/refresh-flow', { credentials: 'include' })
  .then(r => r.json())
  .then(console.log)

Returns:

  • Token presence (idTokenPresent, refreshTokenPresent)
  • Verification result (valid, expired, invalid)
  • Refresh attempt status (refreshAttempted, refreshSucceeded, newTokenIssued)
  • User info and identity data

/api/test/force-refresh

Forces a refresh token flow by directly calling refreshTokens():

fetch('/api/test/force-refresh', { credentials: 'include' })
  .then(r => r.json())
  .then(console.log)

Validates:

  • Refresh token cookie is present and readable
  • Cognito accepts the refresh token (REFRESH_TOKEN_AUTH flow)
  • New tokens are received and verified
  • New ID token cookie is set correctly

Note: To test automatic refresh without waiting 24 hours, temporarily reduce Cognito ID token validity using AWS CLI (see setup documentation for details).


Long-Lived Sessions: 3 Months

Requirement

Users stay signed in for up to ~3 months without manual re-authentication.

Implementation

  • ID tokens: Stored in auth-token cookie with Max-Age of 90 days
  • Refresh tokens: Stored in auth-refresh-token cookie with Max-Age of 90 days
  • ID/Access token validity: 24 hours (Cognito maximum)
  • Refresh token validity: 90 days
  • Automatic refresh: When /api/me (or other protected endpoints) detects an expired ID token, it automatically uses the refresh token to get a new ID token and updates the cookie
  • Error handling: Token verification distinguishes between expired tokens (which trigger refresh) and invalid/malformed tokens (which return 401 immediately without refresh attempt)

Result: Users stay logged in for the full 90-day refresh token lifetime without manual re-authentication, as long as they make requests at least once every 24 hours (ID token lifetime).


Identity Storage

Identity data is split between two systems:

  • Cognito: Email (source of truth), authentication credentials
  • DynamoDB: Profile data (display_name, avatar_url, roles, guest_email)

Important: Email is the source of truth in Cognito. It is read from the ID token and never stored in DynamoDB. This ensures email changes in Cognito are immediately reflected without stale data in DynamoDB.

The sub claim (Cognito user ID) serves as the identity anchor and primary key for DynamoDB lookups.