Skip to main content

Shopify Integration Implementation

Status: βœ… Completed (Nov 14, 2025)

This document describes the actual implementation of Shopify OAuth integration in the Eli Health app, including architecture, critical bugs discovered, and solutions.

Table of Contents​

Overview​

The Shopify integration allows Eli Health app users to link their Shopify customer accounts using OAuth 2.0 with PKCE (Proof Key for Code Exchange). This enables:

  • Secure account linking without exposing credentials
  • Access to Shopify customer data (orders, profile)
  • Future in-app purchasing capabilities

Technology Stack​

  • OAuth Flow: Shopify Customer Account API with PKCE
  • Mobile: React Native with react-native-inappbrowser-reborn
  • Backend: NestJS with TypeORM
  • Security: Token encryption at rest using Google KMS
  • Token Management: Automatic refresh token rotation

Architecture​

Security Considerations​

  1. PKCE Flow: Mobile app generates code_verifier/code_challenge
  2. Server-Side Token Exchange: Access tokens never touch the mobile app
  3. Encrypted Storage: All tokens encrypted at rest using Google KMS
  4. Token Refresh: Automatic refresh with 5-minute buffer
  5. CSRF Protection: State parameter validation

Key Design Decisions​

  • No "Bearer " prefix: Shopify Customer Account API requires raw token (not "Bearer TOKEN")
  • Dual-bucket storage: Firebase Storage for temp uploads, GCS for permanent storage
  • Load-then-save pattern: Prevents encrypted columns from being set to null
  • Token expiry buffer: Refresh tokens 5 minutes before expiry

Environment Variables​

Mobile App (eli-app)​

// src/services/shopify-oauth.ts
const SHOPIFY_CLIENT_ID = '2d989895-5913-4700-b2be-48f1af8929da';
const SHOPIFY_AUTHORIZE_URL = 'https://account.eli.health/authentication/oauth/authorize';
const SHOPIFY_TOKEN_URL = 'https://account.eli.health/authentication/oauth/token';
const SHOPIFY_CALLBACK_URL = `${environment.apiEndpoint}/shopify/callback`;

Backend API (eli-backend-api)​

Add to .env:

# Shopify OAuth Configuration
SHOPIFY_OAUTH_CLIENT_ID=2d989895-5913-4700-b2be-48f1af8929da
SHOPIFY_OAUTH_TOKEN_URL=https://account.eli.health/authentication/oauth/token
SHOPIFY_OAUTH_CALLBACK_URL=https://api-dev.eli.health/shopify/callback

Environment-specific callback URLs:

  • Development: https://api-dev.eli.health/shopify/callback
  • Staging: https://api-staging.eli.health/shopify/callback
  • Production: https://api.eli.health/shopify/callback

All callback URLs must be registered in Shopify Customer Account API settings.

Database Schema​

User Entity Updates​

@Entity('user')
export class User {
// Existing fields...

@Column({ type: 'varchar', nullable: true })
@EncryptedColumn() // Auto-encrypts using Google KMS
shopifyCustomerId: string | null;

@Column({ type: 'varchar', nullable: true })
@EncryptedColumn()
shopifyAccessToken: string | null;

@Column({ type: 'varchar', nullable: true })
@EncryptedColumn()
shopifyRefreshToken: string | null;

@Column({ type: 'timestamp', nullable: true })
shopifyTokenExpiry: Date | null;

@Column({ type: 'timestamp', nullable: true })
shopifyLinkedAt: Date | null;
}

Migrations​

  1. AddShopifyFieldsToUserTable (1762960706006)

    • Added initial Shopify fields (customerId, accessToken, tokenExpiry, linkedAt)
  2. AddShopifyRefreshTokenToUserTable (1763332957000)

    • Added shopifyRefreshToken for token refresh capability

Implementation Flow​

Complete OAuth Flow Sequence​

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Mobile β”‚ β”‚ Backend β”‚ β”‚ Shopify β”‚ β”‚ GCS β”‚
β”‚ App β”‚ β”‚ API β”‚ β”‚ OAuth β”‚ β”‚ Bucket β”‚
β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜
β”‚ β”‚ β”‚ β”‚
β”‚ 1. User clicks β”‚ β”‚ β”‚
β”‚ "Connect" β”‚ β”‚ β”‚
│─────────────────── β”‚ β”‚
β”‚ β”‚ β”‚ β”‚
β”‚ 2. Generate PKCE β”‚ β”‚ β”‚
β”‚ code_verifier β”‚ β”‚ β”‚
β”‚ code_challenge β”‚ β”‚ β”‚
β”‚<────────────────── β”‚ β”‚
β”‚ β”‚ β”‚ β”‚
β”‚ 3. Store verifier β”‚ β”‚ β”‚
β”‚ in AsyncStorageβ”‚ β”‚ β”‚
β”‚<────────────────── β”‚ β”‚
β”‚ β”‚ β”‚ β”‚
β”‚ 4. Open browser β”‚ β”‚ β”‚
β”‚ with auth URL β”‚ β”‚ β”‚
│───────────────────────────────────────>β”‚ β”‚
β”‚ β”‚ β”‚ β”‚
β”‚ 5. User logs in β”‚ β”‚ β”‚
β”‚ and authorizes β”‚ β”‚ β”‚
β”‚<───────────────────────────────────────│ β”‚
β”‚ β”‚ β”‚ β”‚
β”‚ 6. Redirect to β”‚ β”‚ β”‚
β”‚ callback URL β”‚ β”‚ β”‚
β”‚ with code β”‚ β”‚ β”‚
│───────────────────>β”‚ β”‚ β”‚
β”‚ β”‚ β”‚ β”‚
β”‚ 7. Return code & β”‚ β”‚ β”‚
β”‚ state to app β”‚ β”‚ β”‚
β”‚<──────────────────│ β”‚ β”‚
β”‚ β”‚ β”‚ β”‚
β”‚ 8. Call backend β”‚ β”‚ β”‚
β”‚ /oauth/exchangeβ”‚ β”‚ β”‚
β”‚ with code, β”‚ β”‚ β”‚
β”‚ state, verifierβ”‚ β”‚ β”‚
│───────────────────>β”‚ β”‚ β”‚
β”‚ β”‚ β”‚ β”‚
β”‚ β”‚ 9. Verify state β”‚ β”‚
β”‚ β”‚<─────────────────── β”‚
β”‚ β”‚ β”‚ β”‚
β”‚ β”‚ 10. Exchange code β”‚ β”‚
β”‚ β”‚ for token β”‚ β”‚
β”‚ β”‚ (PKCE) β”‚ β”‚
β”‚ │───────────────────>β”‚ β”‚
β”‚ β”‚ β”‚ β”‚
β”‚ β”‚ 11. Access token & β”‚ β”‚
β”‚ β”‚ refresh token β”‚ β”‚
β”‚ β”‚<───────────────────│ β”‚
β”‚ β”‚ β”‚ β”‚
β”‚ β”‚ 12. Get customer IDβ”‚ β”‚
β”‚ β”‚ from Shopify β”‚ β”‚
β”‚ │───────────────────>β”‚ β”‚
β”‚ β”‚ β”‚ β”‚
β”‚ β”‚ 13. Customer data β”‚ β”‚
β”‚ β”‚<───────────────────│ β”‚
β”‚ β”‚ β”‚ β”‚
β”‚ β”‚ 14. Encrypt tokens β”‚ β”‚
β”‚ β”‚ with KMS β”‚ β”‚
β”‚ │───────────────────────────────────────>β”‚
β”‚ β”‚ β”‚ β”‚
β”‚ β”‚ 15. Store in DB β”‚ β”‚
β”‚ β”‚ (encrypted) β”‚ β”‚
β”‚ β”‚<───────────────────────────────────────│
β”‚ β”‚ β”‚ β”‚
β”‚ 16. Success β”‚ β”‚ β”‚
β”‚<──────────────────│ β”‚ β”‚
β”‚ β”‚ β”‚ β”‚

Key Flow Points​

  1. PKCE Generation: Mobile app generates and stores code_verifier locally
  2. Browser Flow: In-app browser handles OAuth authorization
  3. Callback Interception: Backend intercepts callback, returns code/state to app
  4. Server-Side Exchange: Backend exchanges code for token (never exposed to app)
  5. Customer ID Fetch: Backend fetches Shopify customer ID using access token
  6. Encrypted Storage: All tokens encrypted with Google KMS before DB storage

Backend Implementation​

Files​

  • src/modules/shopify/shopify.module.ts - Module definition
  • src/modules/shopify/shopify.controller.ts - REST endpoints
  • src/modules/shopify/shopify.service.ts - Business logic
  • src/modules/deep-links/shopify-callback.controller.ts - OAuth callback handler

Key Methods​

ShopifyService.exchangeCodeForToken()​

Exchanges authorization code for access token using PKCE:

async exchangeCodeForToken(userId: string, dto: ExchangeTokenDto): Promise<void> {
// 1. Exchange code for access token (PKCE)
const params = new URLSearchParams({
grant_type: 'authorization_code',
client_id: this.clientId,
redirect_uri: this.callbackUrl,
code: dto.code,
code_verifier: dto.codeVerifier, // PKCE verification
});

const response = await axios.post(this.tokenUrl, params.toString(), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});

const { access_token, refresh_token, expires_in } = response.data;

// 2. Fetch customer ID from Shopify
const customerId = await this.getCustomerId(access_token);

// 3. Load user first (prevents encrypted column null bug)
const user = await this.userRepository.findOne({ where: { id: userId } });

// 4. Update user with encrypted tokens
user.shopifyCustomerId = customerId;
user.shopifyAccessToken = access_token;
user.shopifyRefreshToken = refresh_token;
user.shopifyTokenExpiry = new Date(Date.now() + expires_in * 1000);
user.shopifyLinkedAt = new Date();

await this.userRepository.save(user); // Auto-encrypts via @EncryptedColumn
}

ShopifyService.getCustomerId()​

CRITICAL: No "Bearer " prefix!

private async getCustomerId(accessToken: string): Promise<string> {
const graphqlUrl = 'https://account.eli.health/customer/api/2025-10/graphql';
const graphqlQuery = `
query {
customer {
id
}
}
`;

const response = await axios.post(
graphqlUrl,
{ query: graphqlQuery },
{
headers: {
'Authorization': accessToken, // ❌ NOT "Bearer " + accessToken
'Content-Type': 'application/json',
'Origin': 'https://account.eli.health', // Required!
},
},
);

return response.data?.data?.customer?.id;
}

ShopifyService.refreshAccessToken()​

Automatic token refresh with 5-minute buffer:

private async ensureValidToken(userId: string): Promise<string> {
const user = await this.userRepository.findOne({
where: { id: userId },
select: ['shopifyAccessToken', 'shopifyTokenExpiry'],
});

// Check if token expires in next 5 minutes
const now = new Date();
const bufferTime = 5 * 60 * 1000;
const expiryWithBuffer = new Date(user.shopifyTokenExpiry.getTime() - bufferTime);

if (now >= expiryWithBuffer) {
return await this.refreshAccessToken(userId);
}

return user.shopifyAccessToken;
}

Frontend Implementation​

Files​

  • src/screens/Shopify/ShopifySettingsScreen.tsx - Main settings screen
  • src/screens/Shopify/ShopifyOAuthCallbackScreen.tsx - OAuth callback handler
  • src/services/shopify-oauth.ts - OAuth service

Key Methods​

ShopifyOAuthService.initiateOAuthFlow()​

Starts OAuth flow with PKCE:

static async initiateOAuthFlow(): Promise<{ success: boolean; code?: string; state?: string }> {
// 1. Generate PKCE parameters
const codeVerifier = generateCodeVerifier(); // 128 random chars
const codeChallenge = generateCodeChallenge(codeVerifier); // SHA256 + base64url
const state = generateState(); // CSRF protection

// 2. Store in AsyncStorage
await AsyncStorage.setItem(PKCE_CODE_VERIFIER_KEY, codeVerifier);
await AsyncStorage.setItem(PKCE_STATE_KEY, state);

// 3. Build authorization URL
const authUrl = `${SHOPIFY_AUTHORIZE_URL}?` +
`client_id=${SHOPIFY_CLIENT_ID}&` +
`redirect_uri=${encodeURIComponent(SHOPIFY_CALLBACK_URL)}&` +
`state=${state}&` +
`code_challenge=${codeChallenge}&` +
`code_challenge_method=S256&` +
`response_type=code&` +
`scope=openid+email+customer-account-api:full&` +
`prompt=login`;

// 4. Open in-app browser
const result = await InAppBrowser.openAuth(authUrl, SHOPIFY_CALLBACK_URL, {
ephemeralWebSession: false,
});

// 5. Parse callback URL and return code/state
if (result.type === 'success' && result.url) {
const { queryParams } = parseUrl(result.url);
return {
success: true,
code: queryParams['code'],
state: queryParams['state'],
};
}

return { success: false };
}

ShopifyOAuthService.exchangeTokenViaBackend()​

Secure server-side token exchange:

static async exchangeTokenViaBackend(code: string, state: string): Promise<{
success: boolean;
error?: string;
}> {
// 1. Verify state matches (CSRF protection)
const storedState = await AsyncStorage.getItem(PKCE_STATE_KEY);
if (state !== storedState) {
return { success: false, error: 'State mismatch - possible CSRF attack' };
}

// 2. Get code_verifier from AsyncStorage
const codeVerifier = await AsyncStorage.getItem(PKCE_CODE_VERIFIER_KEY);
if (!codeVerifier) {
return { success: false, error: 'Code verifier not found' };
}

// 3. Call backend to exchange token SERVER-SIDE
const response = await apiClient.post('/shopify/oauth/exchange', {
code,
state,
codeVerifier,
});

if (response.success) {
// 4. Clean up PKCE keys
await AsyncStorage.removeItem(PKCE_CODE_VERIFIER_KEY);
await AsyncStorage.removeItem(PKCE_STATE_KEY);
}

return response;
}

Critical Issues & Fixes​

🚨 Issue #1: NO "Bearer " Prefix​

Problem: Shopify Customer Account API rejects "Bearer " prefix in Authorization header.

Error:

{
"errors": [
{
"message": "Access denied. Check the supplied access token."
}
]
}

Root Cause: Shopify's Customer Account API expects raw token, not Bearer token format.

Fix (shopify.service.ts:141):

// ❌ WRONG
headers: {
'Authorization': `Bearer ${accessToken}`,
}

// βœ… CORRECT
headers: {
'Authorization': accessToken,
}

Files Changed:

  • eli-backend-api/src/modules/shopify/shopify.service.ts:141
  • eli-backend-api/src/modules/shopify/shopify.service.ts:346
  • eli-backend-api/src/modules/shopify/shopify.service.ts:419

🚨 Issue #2: Database Encryption Null Constraint​

Problem: Using repository.update() sets encrypted columns to null, violating NOT NULL constraints.

Error:

ERROR: null value in column "first_name" of relation "user" violates not-null constraint

Root Cause: TypeORM's update() doesn't trigger @EncryptedColumn transformer, so encrypted fields become null.

Fix (shopify.service.ts:80-92):

// ❌ WRONG - sets encrypted columns to null
await this.userRepository.update(userId, {
shopifyCustomerId: customerId,
shopifyAccessToken: accessToken,
// ... other encrypted columns become null!
});

// βœ… CORRECT - load first, then save
const user = await this.userRepository.findOne({ where: { id: userId } });
user.shopifyCustomerId = customerId;
user.shopifyAccessToken = accessToken;
user.shopifyRefreshToken = refreshToken;
user.shopifyTokenExpiry = tokenExpiry;
user.shopifyLinkedAt = new Date();
await this.userRepository.save(user); // Triggers @EncryptedColumn transformer

Pattern: Always use findOne() + save() for entities with @EncryptedColumn.

Files Changed:

  • eli-backend-api/src/modules/shopify/shopify.service.ts:80-92
  • eli-backend-api/src/modules/shopify/shopify.service.ts:259-270

🚨 Issue #3: Origin Header Required​

Problem: Shopify GraphQL requests fail without Origin header.

Fix: Add Origin header matching Shopify settings:

headers: {
'Origin': 'https://account.eli.health',
}

🚨 Issue #4: GraphQL URL Path​

Problem: Incorrect GraphQL endpoint path.

Fix:

// ❌ WRONG
const graphqlUrl = 'https://account.eli.health/account/customer/api/2025-10/graphql';

// βœ… CORRECT
const graphqlUrl = 'https://account.eli.health/customer/api/2025-10/graphql';

API Endpoints​

POST /shopify/oauth/exchange​

Exchange OAuth code for access token (server-side).

Authentication: Required (Firebase JWT)

Request:

{
"code": "authorization_code_from_shopify",
"state": "csrf_protection_state",
"codeVerifier": "pkce_code_verifier_128_chars"
}

Response:

{
"success": true,
"message": "Shopify account linked successfully"
}

Get Shopify link status for current user.

Authentication: Required

Response:

{
"isLinked": true,
"shopifyCustomerId": "gid://shopify/Customer/123456",
"linkedAt": "2025-11-14T20:47:00.000Z"
}

Unlink Shopify account for current user.

Authentication: Required

Response:

{
"success": true,
"message": "Shopify account unlinked successfully"
}

GET /shopify/customer​

Get customer information from Shopify.

Authentication: Required

Response:

{
"success": true,
"data": {
"id": "gid://shopify/Customer/123456",
"email": "user@example.com",
"firstName": "John",
"lastName": "Doe",
"phone": "+1234567890"
}
}

GET /shopify/orders​

Get customer orders from Shopify.

Authentication: Required

Response:

{
"success": true,
"data": {
"orders": {
"edges": [
{
"node": {
"id": "gid://shopify/Order/123",
"name": "#1001",
"orderNumber": 1001,
"processedAt": "2025-11-01T10:00:00Z",
"financialStatus": "PAID",
"fulfillmentStatus": "FULFILLED",
"totalPrice": {
"amount": "99.99",
"currencyCode": "USD"
},
"lineItems": {
"edges": [
{
"node": {
"title": "Product Name",
"quantity": 2,
"price": {
"amount": "49.99",
"currencyCode": "USD"
}
}
}
]
}
}
}
]
}
}
}

Testing​

Manual Testing Checklist​

  • User can initiate OAuth flow from Settings
  • In-app browser opens Shopify login
  • User can log in with Shopify credentials
  • Callback redirects back to app
  • Backend exchanges code for token successfully
  • Customer ID is fetched and stored
  • Tokens are encrypted in database
  • Link status shows "Connected"
  • User can fetch customer info
  • User can fetch orders
  • User can disconnect account
  • Tokens auto-refresh before expiry

Testing Different Accounts​

To test with different Shopify accounts:

// Call this to clear local session and force new login
await ShopifyOAuthService.clearLocalSession();

Verification Queries​

-- Check encrypted tokens in database
SELECT
id,
email,
shopify_customer_id,
shopify_token_expiry,
shopify_linked_at,
LENGTH(shopify_access_token) as token_length,
LENGTH(shopify_refresh_token) as refresh_token_length
FROM "user"
WHERE shopify_customer_id IS NOT NULL;

Common Errors​

ErrorCauseFix
"Access denied"Bearer prefix in headerRemove "Bearer " prefix
"State mismatch"CSRF validation failedCheck AsyncStorage state
"Code verifier not found"Missing from storageVerify PKCE generation
"null value in column"Using repository.update()Use findOne() + save()

References​