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
- Architecture
- Environment Variables
- Database Schema
- Implementation Flow
- Backend Implementation
- Frontend Implementation
- Critical Issues & Fixes
- API Endpoints
- Testing
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β
- PKCE Flow: Mobile app generates code_verifier/code_challenge
- Server-Side Token Exchange: Access tokens never touch the mobile app
- Encrypted Storage: All tokens encrypted at rest using Google KMS
- Token Refresh: Automatic refresh with 5-minute buffer
- 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β
-
AddShopifyFieldsToUserTable (1762960706006)
- Added initial Shopify fields (customerId, accessToken, tokenExpiry, linkedAt)
-
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β
- PKCE Generation: Mobile app generates and stores code_verifier locally
- Browser Flow: In-app browser handles OAuth authorization
- Callback Interception: Backend intercepts callback, returns code/state to app
- Server-Side Exchange: Backend exchanges code for token (never exposed to app)
- Customer ID Fetch: Backend fetches Shopify customer ID using access token
- Encrypted Storage: All tokens encrypted with Google KMS before DB storage
Backend Implementationβ
Filesβ
src/modules/shopify/shopify.module.ts- Module definitionsrc/modules/shopify/shopify.controller.ts- REST endpointssrc/modules/shopify/shopify.service.ts- Business logicsrc/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 screensrc/screens/Shopify/ShopifyOAuthCallbackScreen.tsx- OAuth callback handlersrc/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:141eli-backend-api/src/modules/shopify/shopify.service.ts:346eli-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-92eli-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β
Get Shopify link status for current user.
Authentication: Required
Response:
{
"isLinked": true,
"shopifyCustomerId": "gid://shopify/Customer/123456",
"linkedAt": "2025-11-14T20:47:00.000Z"
}
DELETE /shopify/unlinkβ
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β
| Error | Cause | Fix |
|---|---|---|
| "Access denied" | Bearer prefix in header | Remove "Bearer " prefix |
| "State mismatch" | CSRF validation failed | Check AsyncStorage state |
| "Code verifier not found" | Missing from storage | Verify PKCE generation |
| "null value in column" | Using repository.update() | Use findOne() + save() |