Skip to content

AI Prompt: Forma3D.Connect — Shopify OAuth Integration

Purpose: This prompt instructs an AI to implement Shopify OAuth 2.0 authentication
Estimated Effort: 16 hours (~1 week)
Prerequisites: Phase 6 completed (Production readiness), Multi-tenancy in place
Output: OAuth-based Shopify authentication with token storage, refresh, and migration from static tokens
Status:PENDING


🎯 Mission

You are continuing development of Forma3D.Connect. Your task is to implement Shopify OAuth 2.0 authentication — replacing the current static access token approach with a proper OAuth flow that works with both development stores and production merchant stores.

Why this is needed:

As of January 1, 2026, Shopify deprecated legacy custom apps for merchants. New merchant stores can only use OAuth-authenticated apps. The current static SHOPIFY_ACCESS_TOKEN approach only works for Partner development stores with legacy custom apps.

This implementation delivers:

  • OAuth 2.0 authorization code flow for Shopify app installation
  • Secure token storage in database (per-shop/per-tenant)
  • Token refresh mechanism for expiring offline access tokens
  • Backward compatibility with existing static token configuration (for migration)
  • App installation and uninstallation handling

Authentication flow after implementation:

Merchant clicks Install → OAuth consent → Callback → Token stored → API calls use stored token

📋 Context

Current Implementation (Static Token)

The current implementation uses a static access token from environment variables:

// apps/api/src/shopify/shopify-api.client.ts
@Injectable()
export class ShopifyApiClient {
  private readonly accessToken: string;

  constructor(private readonly configService: ConfigService) {
    this.accessToken = this.configService.get<string>('SHOPIFY_ACCESS_TOKEN', '');
    // Uses this static token for all API calls
  }

  private async request<T>(...) {
    const headers: HeadersInit = {
      'X-Shopify-Access-Token': this.accessToken,
    };
    // ...
  }
}

Environment variables used:

Variable Purpose
SHOPIFY_SHOP_DOMAIN Single shop domain (e.g., forma3d-dev.myshopify.com)
SHOPIFY_API_KEY App API key (Client ID)
SHOPIFY_API_SECRET App API secret (Client Secret)
SHOPIFY_ACCESS_TOKEN Static access token (from legacy custom app)
SHOPIFY_WEBHOOK_SECRET Webhook HMAC verification secret
SHOPIFY_API_VERSION API version (e.g., 2026-01)

Multi-Tenancy Already Exists

The system already has multi-tenancy support:

model Tenant {
  id        String   @id @default(uuid())
  name      String
  slug      String   @unique
  // ... relations to all major models
}

Each tenant can have its own Shopify shop connection.

What Needs to Change

Component Current State Target State
Token storage Environment variable Database (ShopifyShop model)
Token type Static (non-expiring) OAuth offline token (expiring, 90-day refresh)
Shop support Single shop Multi-shop per tenant
Installation Manual (copy token) OAuth flow (automated)
API client Single static token Per-request token lookup

🛠️ Tech Stack Reference

All technologies from previous phases remain. Additional considerations:

Package/Concept Purpose
crypto (Node.js) HMAC verification, nonce generation
@nestjs/cache-manager Token caching (optional optimization)
Prisma ShopifyShop model for token storage

Shopify OAuth Documentation:


🏗️ Implementation Features

F-OAUTH.1: Database Schema Updates (2 hours)

Create a new model to store Shopify shop connections and tokens.

Prisma Schema Addition:

// Add to prisma/schema.prisma

model ShopifyShop {
  id           String   @id @default(uuid())
  tenantId     String
  shopDomain   String   // e.g., "example.myshopify.com"
  accessToken  String   // Encrypted OAuth access token
  tokenType    String   @default("offline") // "offline" or "online"
  scopes       String[] // Granted scopes

  // For expiring tokens (recommended)
  expiresAt     DateTime?
  refreshToken  String?   // For token refresh

  // Installation metadata
  installedAt   DateTime  @default(now())
  uninstalledAt DateTime?
  isActive      Boolean   @default(true)

  // Webhook configuration
  webhookSecret String?   // Per-shop webhook secret

  // Timestamps
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // Relations
  tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)

  @@unique([tenantId, shopDomain])
  @@index([tenantId])
  @@index([shopDomain])
  @@index([isActive])
}

Update Tenant model:

model Tenant {
  // ... existing fields

  // Add relation
  shopifyShops ShopifyShop[]
}

Migration:

npx prisma migrate dev --name add_shopify_oauth

F-OAUTH.2: OAuth Flow Endpoints (4 hours)

Implement the OAuth 2.0 authorization code flow.

New Controller: apps/api/src/shopify/shopify-oauth.controller.ts

@Controller('shopify/oauth')
export class ShopifyOAuthController {

  /**
   * Step 1: Initiate OAuth flow
   * GET /shopify/oauth/authorize?shop=example.myshopify.com
   *
   * Redirects merchant to Shopify's OAuth consent screen
   */
  @Get('authorize')
  async authorize(
    @Query('shop') shop: string,
    @Query('tenantId') tenantId: string, // Optional: for multi-tenant
    @Res() res: Response,
  ): Promise<void>;

  /**
   * Step 2: OAuth callback
   * GET /shopify/oauth/callback?code=xxx&shop=xxx&state=xxx&hmac=xxx
   *
   * Exchanges authorization code for access token
   */
  @Get('callback')
  async callback(
    @Query('code') code: string,
    @Query('shop') shop: string,
    @Query('state') state: string,
    @Query('hmac') hmac: string,
    @Query('timestamp') timestamp: string,
    @Res() res: Response,
  ): Promise<void>;

  /**
   * App uninstallation webhook
   * POST /shopify/oauth/uninstall
   *
   * Handles app/uninstalled webhook to clean up tokens
   */
  @Post('uninstall')
  async handleUninstall(
    @Headers('x-shopify-hmac-sha256') hmac: string,
    @Headers('x-shopify-shop-domain') shop: string,
    @Body() body: unknown,
  ): Promise<void>;
}

OAuth Flow Implementation Details:

  1. Authorize endpoint:
  2. Validate shop domain format (must be *.myshopify.com)
  3. Generate cryptographic nonce for state parameter
  4. Store state in cache/session with tenantId
  5. Build authorization URL with scopes:
    https://{shop}/admin/oauth/authorize?
      client_id={API_KEY}&
      scope={SCOPES}&
      redirect_uri={CALLBACK_URL}&
      state={NONCE}
    
  6. Required scopes: read_orders,write_orders,read_products,write_products,read_fulfillments,write_fulfillments,read_inventory,read_merchant_managed_fulfillment_orders,write_merchant_managed_fulfillment_orders

  7. Callback endpoint:

  8. Verify HMAC signature using API secret
  9. Validate state matches stored nonce (prevent CSRF)
  10. Validate shop domain matches
  11. Exchange code for access token:
    POST https://{shop}/admin/oauth/access_token
    {
      "client_id": "{API_KEY}",
      "client_secret": "{API_SECRET}",
      "code": "{CODE}"
    }
    
  12. Store token in database (encrypted)
  13. Register webhooks for this shop
  14. Redirect to dashboard with success message

  15. Uninstall webhook:

  16. Verify HMAC signature
  17. Mark shop as inactive (soft delete)
  18. Clear cached tokens
  19. Log uninstallation event

F-OAUTH.3: Token Storage Service (3 hours)

Create a service to manage token storage, retrieval, and refresh.

New Service: apps/api/src/shopify/shopify-token.service.ts

@Injectable()
export class ShopifyTokenService {
  /**
   * Store a new access token for a shop
   */
  async storeToken(
    tenantId: string,
    shopDomain: string,
    tokenData: {
      accessToken: string;
      scopes: string[];
      expiresAt?: Date;
      refreshToken?: string;
    }
  ): Promise<ShopifyShop>;

  /**
   * Get access token for a shop
   * Handles token refresh if expired
   */
  async getAccessToken(tenantId: string, shopDomain: string): Promise<string>;

  /**
   * Refresh an expiring token
   */
  async refreshToken(shop: ShopifyShop): Promise<ShopifyShop>;

  /**
   * Check if token needs refresh (within 24 hours of expiry)
   */
  isTokenExpiringSoon(shop: ShopifyShop): boolean;

  /**
   * Mark shop as uninstalled
   */
  async markUninstalled(shopDomain: string): Promise<void>;

  /**
   * Encrypt token before storage
   */
  private encryptToken(token: string): string;

  /**
   * Decrypt token for use
   */
  private decryptToken(encryptedToken: string): string;
}

Token Encryption:

Use AES-256-GCM encryption with a key from environment variable:

// Environment variable: SHOPIFY_TOKEN_ENCRYPTION_KEY (32-byte hex string)

import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';

private encryptToken(token: string): string {
  const key = Buffer.from(process.env.SHOPIFY_TOKEN_ENCRYPTION_KEY, 'hex');
  const iv = randomBytes(16);
  const cipher = createCipheriv('aes-256-gcm', key, iv);

  let encrypted = cipher.update(token, 'utf8', 'hex');
  encrypted += cipher.final('hex');

  const authTag = cipher.getAuthTag();

  // Return iv:authTag:encrypted
  return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
}

F-OAUTH.4: Update ShopifyApiClient (3 hours)

Modify the existing API client to support per-shop tokens.

Updated: apps/api/src/shopify/shopify-api.client.ts

@Injectable()
export class ShopifyApiClient {
  private readonly logger = new Logger(ShopifyApiClient.name);
  private readonly apiVersion: string;
  private readonly timeoutMs: number;

  // Legacy mode: use static token from env
  private readonly legacyMode: boolean;
  private readonly legacyAccessToken: string;
  private readonly legacyShopDomain: string;

  constructor(
    private readonly configService: ConfigService,
    private readonly tokenService: ShopifyTokenService // NEW
  ) {
    // Check if using legacy static token or OAuth
    this.legacyAccessToken = this.configService.get<string>('SHOPIFY_ACCESS_TOKEN', '');
    this.legacyShopDomain = this.configService.get<string>('SHOPIFY_SHOP_DOMAIN', '');
    this.legacyMode = Boolean(this.legacyAccessToken && this.legacyShopDomain);

    if (this.legacyMode) {
      this.logger.warn('Using legacy static token mode. Consider migrating to OAuth.');
    }
  }

  /**
   * Make API request with per-shop token
   */
  async requestForShop<T>(
    tenantId: string,
    shopDomain: string,
    method: 'GET' | 'POST' | 'PUT' | 'DELETE',
    endpoint: string,
    body?: unknown
  ): Promise<T> {
    const accessToken = await this.tokenService.getAccessToken(tenantId, shopDomain);
    const baseUrl = `https://${shopDomain}/admin/api/${this.apiVersion}`;

    return this.executeRequest<T>(baseUrl, accessToken, method, endpoint, body);
  }

  /**
   * Make API request (legacy mode - uses static token)
   * Kept for backward compatibility during migration
   */
  async request<T>(
    method: 'GET' | 'POST' | 'PUT' | 'DELETE',
    endpoint: string,
    body?: unknown
  ): Promise<T> {
    if (!this.legacyMode) {
      throw new Error('Legacy mode not configured. Use requestForShop() instead.');
    }

    const baseUrl = `https://${this.legacyShopDomain}/admin/api/${this.apiVersion}`;
    return this.executeRequest<T>(baseUrl, this.legacyAccessToken, method, endpoint, body);
  }

  private async executeRequest<T>(
    baseUrl: string,
    accessToken: string,
    method: 'GET' | 'POST' | 'PUT' | 'DELETE',
    endpoint: string,
    body?: unknown
  ): Promise<T> {
    // Existing request logic with provided token
  }
}

F-OAUTH.5: Update Dependent Services (2 hours)

Update services that use ShopifyApiClient to pass tenant/shop context.

Services to update:

  1. FulfillmentService - Pass tenantId and shopDomain when creating fulfillments
  2. ShopifyWebhooksService - Look up shop by incoming webhook domain
  3. ShopifyBackfillService - Iterate over all active shops for tenant

Pattern for service updates:

// Before (legacy)
await this.shopifyClient.createFulfillment(orderId, data);

// After (OAuth)
const order = await this.orderRepository.findById(orderId);
const shop = await this.shopService.getActiveShopForTenant(order.tenantId);
await this.shopifyClient.requestForShop(
  order.tenantId,
  shop.shopDomain,
  'POST',
  `/orders/${orderId}/fulfillments.json`,
  { fulfillment: data }
);

Webhook shop lookup:

// In webhook handler
async handleWebhook(shopDomain: string, topic: string, payload: unknown) {
  // Find shop by domain
  const shop = await this.shopifyShopRepository.findByDomain(shopDomain);
  if (!shop || !shop.isActive) {
    throw new UnauthorizedException('Shop not connected');
  }

  // Use shop.tenantId for all operations
  await this.processWebhook(shop.tenantId, topic, payload);
}

F-OAUTH.6: Configuration and Environment (2 hours)

Update configuration to support both legacy and OAuth modes.

New environment variables:

Variable Purpose Required
SHOPIFY_TOKEN_ENCRYPTION_KEY 32-byte hex key for token encryption Yes (OAuth mode)
SHOPIFY_APP_URL Base URL for OAuth callbacks Yes (OAuth mode)
SHOPIFY_SCOPES Comma-separated OAuth scopes Yes (OAuth mode)

Generate encryption key:

# Generate a secure 32-byte key
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

Configuration update:

// apps/api/src/config/configuration.ts

export interface ShopifyConfig {
  // Legacy (static token)
  shopDomain: string;
  accessToken: string;

  // OAuth
  apiKey: string;      // Client ID
  apiSecret: string;   // Client Secret
  appUrl: string;      // For callback URL
  scopes: string[];    // OAuth scopes
  tokenEncryptionKey: string;

  // Common
  webhookSecret: string;
  apiVersion: string;
}

shopify: {
  shopDomain: process.env['SHOPIFY_SHOP_DOMAIN'] || '',
  accessToken: process.env['SHOPIFY_ACCESS_TOKEN'] || '',
  apiKey: process.env['SHOPIFY_API_KEY'] || '',
  apiSecret: process.env['SHOPIFY_API_SECRET'] || '',
  appUrl: process.env['SHOPIFY_APP_URL'] || process.env['APP_URL'] || '',
  scopes: (process.env['SHOPIFY_SCOPES'] || 'read_orders,write_orders,read_products,write_products,read_fulfillments,write_fulfillments,read_inventory,read_merchant_managed_fulfillment_orders,write_merchant_managed_fulfillment_orders').split(','),
  tokenEncryptionKey: process.env['SHOPIFY_TOKEN_ENCRYPTION_KEY'] || '',
  webhookSecret: process.env['SHOPIFY_WEBHOOK_SECRET'] || '',
  apiVersion: process.env['SHOPIFY_API_VERSION'] || '2026-01',
},

🔒 Security Requirements

Token Encryption

  • All access tokens MUST be encrypted at rest using AES-256-GCM
  • Encryption key stored in environment variable, never in code or database
  • Use unique IV (initialization vector) for each token

HMAC Verification

  • All OAuth callbacks MUST verify the HMAC signature
  • Use timing-safe comparison to prevent timing attacks:
import { timingSafeEqual, createHmac } from 'crypto';

function verifyHmac(query: Record<string, string>, secret: string): boolean {
  const { hmac, ...params } = query;

  const message = Object.keys(params)
    .sort()
    .map((key) => `${key}=${params[key]}`)
    .join('&');

  const generatedHmac = createHmac('sha256', secret).update(message).digest('hex');

  return timingSafeEqual(Buffer.from(hmac), Buffer.from(generatedHmac));
}

State Parameter

  • Use cryptographically secure random nonce for state parameter
  • Store state with TTL (5 minutes max)
  • Validate state before processing callback

Webhook Verification

  • Continue using HMAC verification for all webhooks
  • For OAuth apps, webhook secret may be per-shop or app-level

📋 Testing Requirements

Unit Tests

  1. ShopifyTokenService:
  2. Token encryption/decryption roundtrip
  3. Token refresh logic
  4. Expiry detection

  5. ShopifyOAuthController:

  6. HMAC verification
  7. State validation
  8. Error handling

Integration Tests

  1. OAuth flow:
  2. Mock Shopify OAuth endpoints
  3. Test authorization URL generation
  4. Test token exchange

  5. Token storage:

  6. Test token persistence and retrieval
  7. Test encryption in database

E2E Tests

  1. Full OAuth flow (with mocked Shopify):
  2. Initiate authorization
  3. Handle callback
  4. Verify token stored

🔄 Migration Strategy

Phase 1: Deploy OAuth Support (Non-Breaking)

  1. Add ShopifyShop model and migration
  2. Deploy OAuth endpoints (dormant)
  3. Keep legacy mode working
  4. No changes to existing functionality

Phase 2: Test OAuth Flow

  1. Connect development store via OAuth
  2. Verify all API calls work with OAuth token
  3. Test token refresh

Phase 3: Migrate Production

  1. For existing tenant with static token:
  2. Create ShopifyShop record with existing token
  3. Gradually switch to OAuth-based lookups
  4. New tenants use OAuth only

Backward Compatibility

The system MUST support both modes simultaneously:

// In services
if (this.shopifyClient.isLegacyMode()) {
  // Use legacy static token
  await this.shopifyClient.request('GET', '/orders.json');
} else {
  // Use OAuth token lookup
  await this.shopifyClient.requestForShop(tenantId, shopDomain, 'GET', '/orders.json');
}

📁 File Structure

New and modified files:

apps/api/src/
├── shopify/
│   ├── shopify-oauth.controller.ts       # NEW: OAuth endpoints
│   ├── shopify-oauth.service.ts          # NEW: OAuth flow logic
│   ├── shopify-token.service.ts          # NEW: Token management
│   ├── shopify-shop.repository.ts        # NEW: Database access
│   ├── shopify-api.client.ts             # MODIFIED: Multi-shop support
│   ├── shopify.module.ts                 # MODIFIED: Register new services
│   └── __tests__/
│       ├── shopify-oauth.controller.spec.ts
│       ├── shopify-token.service.spec.ts
│       └── shopify-oauth.e2e.spec.ts
├── config/
│   └── configuration.ts                  # MODIFIED: OAuth config
prisma/
└── schema.prisma                         # MODIFIED: ShopifyShop model

✅ Definition of Done

Implementation

  • ShopifyShop model created with migration
  • OAuth authorize endpoint redirects to Shopify
  • OAuth callback exchanges code for token
  • Tokens encrypted at rest in database
  • Token refresh implemented for expiring tokens
  • ShopifyApiClient supports per-shop tokens
  • App uninstall webhook marks shop inactive
  • HMAC verification on all OAuth endpoints
  • Legacy mode continues to work (backward compatible)

Testing

  • Unit tests for token service (encryption, refresh)
  • Integration tests for OAuth flow
  • E2E test for full OAuth authorization flow

Documentation Updates

  • ADR: Create ADR for OAuth authentication decision (docs/03-architecture/adr/)
  • ERD: Update Entity Relationship Diagram with ShopifyShop model
  • C4 Model: Update C4 diagrams to show OAuth flow (Context, Container, Component levels)
  • Domain Model: Update domain model to include ShopifyShop entity and relationships
  • Database Model: Update database documentation with new ShopifyShop table
  • Sequence Diagrams: Add OAuth authorization flow sequence diagram
  • API Documentation: Document new OAuth endpoints in OpenAPI/Swagger
  • Environment Variables: Document new environment variables in deployment docs
  • real-world-testing.md: Update with OAuth setup instructions (completed in this prompt)
  • runbook.md: Add OAuth troubleshooting section (token refresh failures, installation issues)
  • security-audit-checklist.md: Add OAuth-specific security checks

📚 References


Revision History:

Version Date Author Changes
1.0 2026-01-28 Documentation Initial prompt for Shopify OAuth implementation