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:
- Authorize endpoint:
- Validate shop domain format (must be
*.myshopify.com) - Generate cryptographic nonce for state parameter
- Store state in cache/session with tenantId
- Build authorization URL with scopes:
https://{shop}/admin/oauth/authorize? client_id={API_KEY}& scope={SCOPES}& redirect_uri={CALLBACK_URL}& state={NONCE} -
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 -
Callback endpoint:
- Verify HMAC signature using API secret
- Validate state matches stored nonce (prevent CSRF)
- Validate shop domain matches
- Exchange code for access token:
POST https://{shop}/admin/oauth/access_token { "client_id": "{API_KEY}", "client_secret": "{API_SECRET}", "code": "{CODE}" } - Store token in database (encrypted)
- Register webhooks for this shop
-
Redirect to dashboard with success message
-
Uninstall webhook:
- Verify HMAC signature
- Mark shop as inactive (soft delete)
- Clear cached tokens
- 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:
FulfillmentService- Pass tenantId and shopDomain when creating fulfillmentsShopifyWebhooksService- Look up shop by incoming webhook domainShopifyBackfillService- 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¶
- ShopifyTokenService:
- Token encryption/decryption roundtrip
- Token refresh logic
-
Expiry detection
-
ShopifyOAuthController:
- HMAC verification
- State validation
- Error handling
Integration Tests¶
- OAuth flow:
- Mock Shopify OAuth endpoints
- Test authorization URL generation
-
Test token exchange
-
Token storage:
- Test token persistence and retrieval
- Test encryption in database
E2E Tests¶
- Full OAuth flow (with mocked Shopify):
- Initiate authorization
- Handle callback
- Verify token stored
🔄 Migration Strategy¶
Phase 1: Deploy OAuth Support (Non-Breaking)¶
- Add ShopifyShop model and migration
- Deploy OAuth endpoints (dormant)
- Keep legacy mode working
- No changes to existing functionality
Phase 2: Test OAuth Flow¶
- Connect development store via OAuth
- Verify all API calls work with OAuth token
- Test token refresh
Phase 3: Migrate Production¶
- For existing tenant with static token:
- Create ShopifyShop record with existing token
- Gradually switch to OAuth-based lookups
- 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¶
- Shopify OAuth Documentation
- Authorization Code Grant
- Offline Access Tokens
- Expiring Offline Tokens
- Webhook Verification
Revision History:
| Version | Date | Author | Changes |
|---|---|---|---|
| 1.0 | 2026-01-28 | Documentation | Initial prompt for Shopify OAuth implementation |