Backend Integration

JWT verification and authorization in backend services

Overview

Backend services need to verify JWT tokens from authentication cookies and authorize requests based on user roles stored in DynamoDB.

JWT Verification

Using @zooly/auth-srv

The @zooly/auth-srv package provides JWT verification utilities:

import { verifyIdToken } from '@zooly/auth-srv';

async function handler(request: Request) {
  // Extract token from cookie
  const token = request.cookies.get('id_token')?.value;
  
  if (!token) {
    return new Response('Unauthorized', { status: 401 });
  }

  try {
    // Verify JWT signature and claims
    const payload = await verifyIdToken(token);
    
    // payload contains:
    // - sub: Cognito user ID
    // - email: User email (source of truth)
    // - exp: Expiration timestamp
    // - iss: Issuer
    // - aud: Audience (client ID)
    
    return payload;
  } catch (error) {
    return new Response('Invalid token', { status: 401 });
  }
}

Manual JWKS Verification

If you need to verify tokens manually:

import { CognitoJwtVerifier } from 'aws-jwt-verify';

const verifier = CognitoJwtVerifier.create({
  userPoolId: process.env.COGNITO_USER_POOL_ID!,
  tokenUse: 'id',
  clientId: process.env.COGNITO_CLIENT_ID!,
});

async function verifyToken(token: string) {
  try {
    const payload = await verifier.verify(token);
    return payload;
  } catch (error) {
    throw new Error('Token verification failed');
  }
}

Verification Checks

The verifier checks:

  • Signature: Valid JWT signature using JWKS
  • Issuer (iss): Matches Cognito User Pool
  • Token Use: ID token (contains email from Cognito)
  • Expiration (exp): Token not expired
  • Audience (aud): Matches Cognito App Client ID

User Profile Lookup

After verifying the JWT, look up the user profile from DynamoDB:

import { getIdentity } from '@zooly/auth-db';

async function getUserProfile(sub: string) {
  const identity = await getIdentity(sub);
  
  if (!identity) {
    throw new Error('User not found');
  }

  return {
    id: identity.user_id,
    email: payload.email, // From JWT, not DynamoDB
    name: identity.display_name,
    image: identity.avatar_url,
    roles: identity.roles || [],
  };
}

Complete Example: /api/me Endpoint

Here's a complete example of a /api/me endpoint:

import { verifyIdToken } from '@zooly/auth-srv';
import { getIdentity } from '@zooly/auth-db';

export async function GET(request: Request) {
  // Extract token from cookie
  const token = request.cookies.get('id_token')?.value;
  
  if (!token) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }

  try {
    // Verify JWT
    const payload = await verifyIdToken(token);
    
    // Get user profile from DynamoDB
    const identity = await getIdentity(payload.sub);
    
    if (!identity) {
      return Response.json({ error: 'User not found' }, { status: 404 });
    }

    // Return unified profile
    return Response.json({
      id: identity.user_id,
      email: payload.email, // From JWT (source of truth)
      name: identity.display_name,
      image: identity.avatar_url,
      roles: identity.roles || [],
    });
  } catch (error) {
    return Response.json({ error: 'Invalid token' }, { status: 401 });
  }
}

Authorization with Roles

Check user roles for authorization:

import { verifyIdToken } from '@zooly/auth-srv';
import { getIdentity } from '@zooly/auth-db';

async function requireRole(request: Request, requiredRole: string) {
  const token = request.cookies.get('id_token')?.value;
  
  if (!token) {
    throw new Error('Unauthorized');
  }

  const payload = await verifyIdToken(token);
  const identity = await getIdentity(payload.sub);
  
  if (!identity) {
    throw new Error('User not found');
  }

  const roles = identity.roles || [];
  
  if (!roles.includes(requiredRole)) {
    throw new Error('Forbidden');
  }

  return { user: identity, payload };
}

// Usage
async function adminHandler(request: Request) {
  const { user } = await requireRole(request, 'admin');
  // User has admin role
}

Middleware Pattern

Create middleware for protected routes:

import { verifyIdToken } from '@zooly/auth-srv';
import { getIdentity } from '@zooly/auth-db';

export async function authMiddleware(request: Request) {
  const token = request.cookies.get('id_token')?.value;
  
  if (!token) {
    return {
      error: 'Unauthorized',
      status: 401,
    };
  }

  try {
    const payload = await verifyIdToken(token);
    const identity = await getIdentity(payload.sub);
    
    if (!identity) {
      return {
        error: 'User not found',
        status: 404,
      };
    }

    return {
      user: {
        id: identity.user_id,
        email: payload.email,
        name: identity.display_name,
        image: identity.avatar_url,
        roles: identity.roles || [],
      },
      payload,
    };
  } catch (error) {
    return {
      error: 'Invalid token',
      status: 401,
    };
  }
}

// Usage in route handler
export async function GET(request: Request) {
  const auth = await authMiddleware(request);
  
  if (auth.error) {
    return Response.json({ error: auth.error }, { status: auth.status });
  }

  const { user } = auth;
  // Use authenticated user
}

Redirect to Auth App

For unauthenticated requests, redirect to the auth app:

export async function authMiddleware(request: Request) {
  const token = request.cookies.get('id_token')?.value;
  
  if (!token) {
    const url = new URL(request.url);
    const returnTo = encodeURIComponent(url.pathname + url.search);
    const authUrl = `https://auth.zooly.ai?returnTo=${returnTo}`;
    
    return Response.redirect(authUrl, 302);
  }

  // Continue with verification...
}

Guest User Support

For guest checkout endpoints, use getOrCreateByEmail:

import { getOrCreateByEmail } from '@zooly/auth-db';

export async function POST(request: Request) {
  const { email, name } = await request.json();
  
  // Get or create guest identity
  const identity = await getOrCreateByEmail(email, name);
  
  // Use identity.user_id for purchase association
  return Response.json({ userId: identity.user_id });
}

Environment Variables

Required environment variables:

# Cognito
COGNITO_USER_POOL_ID=us-east-1_tvlpQoJAn
COGNITO_CLIENT_ID=2i28c7h1cpm6od29o7vatlncv9
COGNITO_REGION=us-east-1

# DynamoDB
DYNAMODB_IDENTITIES_TABLE=zooly-auth-identities
DYNAMODB_REGION=us-east-1

Error Handling

Handle common errors:

try {
  const payload = await verifyIdToken(token);
} catch (error) {
  if (error instanceof TokenExpiredError) {
    // Token expired - redirect to login
    return Response.redirect('/auth/login', 302);
  }
  
  if (error instanceof TokenInvalidError) {
    // Invalid token - clear cookie and redirect
    return Response.redirect('/auth/login', 302);
  }
  
  // Other errors
  return Response.json({ error: 'Authentication failed' }, { status: 500 });
}

Best Practices

  1. Always verify tokens server-side - Never trust client-provided tokens
  2. Use JWKS for verification - No shared secrets needed
  3. Cache JWKS keys - Keys are rotated infrequently
  4. Get email from JWT - Email is source of truth in Cognito, not DynamoDB
  5. Check roles from DynamoDB - Roles are stored in DynamoDB, not in JWT
  6. Handle token expiration - Redirect to login when tokens expire
  7. Use HttpOnly cookies - Reduces XSS exposure