This document outlines the plan to upgrade OpenFront's API key system from the current basic implementation to a secure, scoped system based on Openship's implementation.
- Model: Extremely basic - only has
userrelationship and tracking fields - Authentication: Uses
x-api-keyheader with direct ID lookup (lines 70-87 infeatures/keystone/index.ts) - Security: MAJOR VULNERABILITY - API keys are stored and transmitted as plain IDs
- Scopes: No scoping system - keys have full user permissions
- Token Format: Plain UUID/ID values
- Show Once: No - keys can be viewed repeatedly
- Model: Comprehensive with
name,tokenSecret(password field),scopes,status,expiresAt,usageCount, etc. - Authentication: Bearer token with bcryptjs verification via Keystone's password field
- Security: Tokens hashed using bcryptjs, only shown once during creation
- Scopes: Granular permission system with 19 different scopes
- Token Format:
osp_prefix + 32-character base62 string - Show Once: ✅ Secure - tokens displayed only during creation
- Plaintext Storage: API keys stored as plain IDs in database
- No Token Security: Keys can be extracted from database directly
- No Scope Limitation: Keys grant full user permissions
- No Expiration: Keys never expire
- No Usage Tracking: No monitoring of API key usage
- Insecure Headers: Uses
x-api-keyinstead of standardAuthorization: Bearer
import {
text,
password,
json,
select,
timestamp,
} from "@keystone-6/core/fields";
import { list } from "@keystone-6/core";
import { isSignedIn, rules, permissions } from "../access";
import { trackingFields } from "./trackingFields";
// OpenFront-specific API key scopes (based on OAuth scopes)
export const API_KEY_SCOPES = {
// Products
"read_products": "View products and inventory",
"write_products": "Manage products and inventory",
// Orders
"read_orders": "View orders and customer information",
"write_orders": "Manage orders and fulfillments",
// Customers (users in our system)
"read_customers": "View customer information",
"write_customers": "Manage customer accounts",
// Fulfillments
"read_fulfillments": "View fulfillment information",
"write_fulfillments": "Manage fulfillments and shipping",
// Checkouts
"read_checkouts": "View checkout sessions",
"write_checkouts": "Manage checkout sessions",
// Discounts
"read_discounts": "View discount codes and promotions",
"write_discounts": "Manage discount codes and promotions",
// Gift Cards
"read_gift_cards": "View gift card information",
"write_gift_cards": "Manage gift cards",
// Returns
"read_returns": "View return requests",
"write_returns": "Manage return requests",
// Sales Channels
"read_sales_channels": "View sales channel information",
"write_sales_channels": "Manage sales channels",
// Payments
"read_payments": "View payment information",
"write_payments": "Process payments and refunds",
// Webhooks
"read_webhooks": "View webhook configurations",
"write_webhooks": "Manage webhook configurations",
// Apps & System
"read_apps": "View installed applications",
"write_apps": "Manage application installations",
} as const;
export type ApiKeyScope = keyof typeof API_KEY_SCOPES;
export const ApiKey = list({
access: {
operation: {
query: isSignedIn,
create: permissions.canManageKeys,
update: permissions.canManageKeys,
delete: permissions.canManageKeys,
},
filter: {
query: rules.canReadApiKeys,
update: rules.canManageApiKeys,
delete: rules.canManageApiKeys,
},
},
hooks: {
validate: {
create: async ({ resolvedData, addValidationError }) => {
if (!resolvedData.scopes || resolvedData.scopes.length === 0) {
addValidationError('At least one scope is required for API keys');
}
},
},
resolveInput: {
create: async ({ resolvedData, context }) => {
// Auto-assign user relationship
return {
...resolvedData,
user: resolvedData.user || (context.session?.itemId ? { connect: { id: context.session.itemId } } : undefined),
};
},
},
},
fields: {
name: text({
validation: { isRequired: true },
ui: {
description: "A descriptive name for this API key (e.g. 'Mobile App', 'Analytics Dashboard')",
},
}),
tokenSecret: password({
validation: { isRequired: true },
ui: {
createView: { fieldMode: "hidden" },
itemView: { fieldMode: "hidden" },
listView: { fieldMode: "hidden" },
description: "Secure API key token (hashed and never displayed)",
},
}),
tokenPreview: text({
ui: {
createView: { fieldMode: "hidden" },
itemView: { fieldMode: "read" },
listView: { fieldMode: "read" },
description: "Preview of the API key (actual key is hidden for security)",
},
}),
scopes: json({
defaultValue: [],
ui: {
description: "Array of scopes for this API key. Available scopes: read_products, write_products, read_orders, etc.",
},
}),
status: select({
type: 'enum',
options: [
{ label: "Active", value: "active" },
{ label: "Inactive", value: "inactive" },
{ label: "Revoked", value: "revoked" },
],
defaultValue: "active",
ui: {
description: "Current status of this API key",
},
}),
expiresAt: timestamp({
ui: {
description: "When this API key expires (optional - leave blank for no expiration)",
},
}),
lastUsedAt: timestamp({
ui: {
createView: { fieldMode: "hidden" },
itemView: { fieldMode: "read" },
description: "Last time this API key was used",
},
}),
usageCount: json({
defaultValue: { total: 0, daily: {} },
ui: {
createView: { fieldMode: "hidden" },
itemView: { fieldMode: "read" },
description: "Usage statistics for this API key",
},
}),
user: relationship({
ref: "User.apiKeys",
ui: {
createView: { fieldMode: "hidden" },
itemView: { fieldMode: "read" },
},
}),
...trackingFields,
},
ui: {
labelField: "name",
listView: {
initialColumns: ["name", "tokenPreview", "scopes", "status", "lastUsedAt", "expiresAt"],
},
description: "Secure API keys for programmatic access to OpenFront",
},
});
// Map API key scopes to internal permissions (reuse OAuth mapping)
export function getPermissionsForScopes(scopes: ApiKeyScope[]): string[] {
const permissions = new Set<string>();
scopes.forEach(scope => {
switch (scope) {
case 'read_products':
permissions.add('canReadProducts');
break;
case 'write_products':
permissions.add('canReadProducts');
permissions.add('canManageProducts');
break;
case 'read_orders':
permissions.add('canReadOrders');
break;
case 'write_orders':
permissions.add('canReadOrders');
permissions.add('canManageOrders');
break;
case 'read_customers':
permissions.add('canReadUsers');
break;
case 'write_customers':
permissions.add('canReadUsers');
permissions.add('canManageUsers');
break;
// ... continue for all scopes
}
});
return Array.from(permissions);
}Replace the insecure x-api-key authentication (lines 69-87) with secure Bearer token authentication:
// Check for OAuth Bearer token authentication
const authHeader = context.req.headers.authorization;
if (authHeader?.startsWith("Bearer ")) {
const accessToken = authHeader.replace("Bearer ", "");
// Try to validate as API key first
if (accessToken.startsWith("of_")) {
console.log('🔑 API KEY DETECTED, VALIDATING...');
try {
// Get all active API keys and test the token against each one
const apiKeys = await context.sudo().query.ApiKey.findMany({
where: { status: { equals: 'active' } },
query: `
id
name
scopes
status
expiresAt
usageCount
tokenSecret { isSet }
user { id }
`,
});
console.log('🔑 CHECKING AGAINST', apiKeys.length, 'ACTIVE API KEYS');
let matchingApiKey = null;
// Test token against each API key using bcryptjs (same as Keystone's default KDF)
for (const apiKey of apiKeys) {
try {
if (!apiKey.tokenSecret?.isSet) continue;
// Get the full API key item with the tokenSecret value
const fullApiKey = await context.sudo().db.ApiKey.findOne({
where: { id: apiKey.id },
});
if (!fullApiKey || typeof fullApiKey.tokenSecret !== 'string') {
continue;
}
// Use bcryptjs to compare - this is exactly what Keystone does internally
const isValid = await bcryptjs.compare(accessToken, fullApiKey.tokenSecret);
if (isValid) {
matchingApiKey = apiKey;
console.log('🔑 FOUND MATCHING API KEY:', apiKey.id);
break;
}
} catch (error) {
console.log('🔑 ERROR VERIFYING API KEY:', error);
continue;
}
}
if (!matchingApiKey) {
console.log('🔑 NO MATCHING API KEY FOUND');
return; // API key not found or invalid
}
if (matchingApiKey.status !== 'active') {
console.log('🔑 API KEY NOT ACTIVE:', matchingApiKey.status);
return; // API key is inactive
}
if (matchingApiKey.expiresAt && new Date() > new Date(matchingApiKey.expiresAt)) {
console.log('🔑 API KEY EXPIRED');
// Auto-revoke expired keys
await context.sudo().query.ApiKey.updateOne({
where: { id: matchingApiKey.id },
data: { status: 'revoked' },
});
return; // API key has expired
}
// Update usage statistics (async, don't wait)
const today = new Date().toISOString().split('T')[0];
const usage = matchingApiKey.usageCount || { total: 0, daily: {} };
usage.total = (usage.total || 0) + 1;
usage.daily[today] = (usage.daily[today] || 0) + 1;
context.sudo().query.ApiKey.updateOne({
where: { id: matchingApiKey.id },
data: {
lastUsedAt: new Date(),
usageCount: usage,
},
}).catch(console.error);
// Return user session with API key scopes attached
if (matchingApiKey.user?.id) {
const session = {
itemId: matchingApiKey.user.id,
listKey,
apiKeyScopes: matchingApiKey.scopes || [] // Attach scopes for permission checking
};
console.log('🔑 RETURNING SESSION:', JSON.stringify(session, null, 2));
return session;
}
} catch (err) {
console.log('🔑 API Key validation error:', err);
return;
}
}
// Continue with existing OAuth token validation...
}Based on Openship's implementation, create:
- CreateApiKey Component - For generating new API keys with client-side token generation
- API Key List Page - For managing existing keys
- API Key Actions - Server actions for CRUD operations
// Client-side token generation (same as Openship)
function generateApiKeyToken(): string {
const prefix = 'of_'; // OpenFront prefix
const randomBytes = new Uint8Array(32);
crypto.getRandomValues(randomBytes);
// Convert to base62 (alphanumeric) for readability
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < randomBytes.length; i++) {
result += chars[randomBytes[i] % chars.length];
}
return prefix + result;
}Add API key relationship to User model:
apiKeys: relationship({
ref: "ApiKey.user",
many: true,
ui: {
displayMode: "cards",
cardFields: ["name", "tokenPreview", "status", "lastUsedAt"],
inlineCreate: { fields: ["name", "scopes", "expiresAt"] },
inlineEdit: { fields: ["name", "scopes", "status", "expiresAt"] },
},
}),Extend the access control to handle API key scopes:
// In access.ts, add API key scope checking
export function hasApiKeyScope(session: any, requiredScope: string): boolean {
if (!session?.apiKeyScopes) return false;
return session.apiKeyScopes.includes(requiredScope);
}
// Update existing permission functions to check API key scopes
export const rules = {
canReadProducts: ({ session }: { session?: any }) => {
if (!session) return false;
// Check if using API key
if (session.apiKeyScopes) {
return hasApiKeyScope(session, 'read_products') || hasApiKeyScope(session, 'write_products');
}
// Regular user permission check
return !!session.data?.role?.canReadProducts;
},
// ... continue for all resources
};- Add new fields to ApiKey table
- Migrate existing API keys (if any) to new format
- CRITICAL: Old keys will need to be regenerated due to security upgrade
- IMPORTANT: This is a breaking change for existing API integrations
- Old
x-api-keyheader method will be removed - All API consumers must upgrade to
Authorization: Bearerformat
- Deploy new API key model and UI
- Generate new secure API keys for existing integrations
- Update all client applications to use new tokens
- Remove old authentication method
- Monitor usage and audit security
- ❌ Plaintext API key storage
- ❌ No scope limitations
- ❌ No expiration support
- ❌ No usage tracking
- ❌ Non-standard authentication header
- ❌ Keys visible repeatedly
- ✅ Hashed token storage using bcryptjs
- ✅ Granular scope-based permissions
- ✅ Optional expiration dates
- ✅ Usage tracking and analytics
- ✅ Standard Bearer token authentication
- ✅ Show-once token display
- ✅ Auto-revocation of expired keys
- ✅ Status management (active/inactive/revoked)
- Unit Tests: Verify token generation, hashing, and validation
- Integration Tests: Test API key authentication flow
- Security Tests: Ensure tokens cannot be extracted from database
- Performance Tests: Verify bcryptjs comparison performance
- Migration Tests: Test upgrade path from old to new system
| OpenFront Scope | Description | Permissions Granted |
|---|---|---|
read_products |
View products | canReadProducts |
write_products |
Manage products | canReadProducts, canManageProducts |
read_orders |
View orders | canReadOrders |
write_orders |
Manage orders | canReadOrders, canManageOrders |
read_customers |
View customers | canReadUsers |
write_customers |
Manage customers | canReadUsers, canManageUsers |
| ... | ... | ... |
- Week 1: Model updates and database migration
- Week 2: Authentication system updates
- Week 3: UI components and client-side implementation
- Week 4: Testing, documentation, and deployment
- Breaking Changes: Existing API integrations will break
- Migration Complexity: Need to coordinate client updates
- Provide clear migration guide
- Offer extended transition period
- Create migration tooling for easy token generation
This implementation brings OpenFront's API key system up to enterprise security standards, matching the proven secure implementation from Openship while adapting the scopes to OpenFront's e-commerce domain.
The upgrade addresses critical security vulnerabilities and provides a foundation for secure API access that can scale with the platform's growth.