Add Etsy shared secret for API calls

Etsy returns 403 'Shared secret is required in x-api-key header' when the
keystring is used on resource endpoints: the keystring is only the OAuth
client id. Store the shared secret alongside it (Settings form + env
fallback) and send it as x-api-key on users/me and receipt requests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
dlawler489 2026-06-13 11:12:50 +10:00
parent 08160775e7
commit 4333b1c55d
5 changed files with 64 additions and 15 deletions

View file

@ -16,6 +16,7 @@ interface EtsyStatus {
interface EtsyConfig { interface EtsyConfig {
configured: boolean; configured: boolean;
apiKeyMasked: string | null; apiKeyMasked: string | null;
sharedSecretMasked: string | null;
redirectUri: string | null; redirectUri: string | null;
source: 'database' | 'environment' | null; source: 'database' | 'environment' | null;
} }
@ -33,6 +34,7 @@ const Settings = () => {
const [etsyStatus, setEtsyStatus] = useState<EtsyStatus | null>(null); const [etsyStatus, setEtsyStatus] = useState<EtsyStatus | null>(null);
const [etsyConfig, setEtsyConfig] = useState<EtsyConfig | null>(null); const [etsyConfig, setEtsyConfig] = useState<EtsyConfig | null>(null);
const [apiKeyInput, setApiKeyInput] = useState(''); const [apiKeyInput, setApiKeyInput] = useState('');
const [sharedSecretInput, setSharedSecretInput] = useState('');
const [redirectUriInput, setRedirectUriInput] = useState(''); const [redirectUriInput, setRedirectUriInput] = useState('');
const [isSavingConfig, setIsSavingConfig] = useState(false); const [isSavingConfig, setIsSavingConfig] = useState(false);
const [isConnecting, setIsConnecting] = useState(false); const [isConnecting, setIsConnecting] = useState(false);
@ -90,10 +92,12 @@ const Settings = () => {
try { try {
await api.put('/etsy/config', { await api.put('/etsy/config', {
apiKey: apiKeyInput.trim() || undefined, apiKey: apiKeyInput.trim() || undefined,
sharedSecret: sharedSecretInput.trim() || undefined,
redirectUri: redirectUriInput.trim(), redirectUri: redirectUriInput.trim(),
}); });
toast.success('Etsy configuration saved'); toast.success('Etsy configuration saved');
setApiKeyInput(''); setApiKeyInput('');
setSharedSecretInput('');
loadEtsyConfig(); loadEtsyConfig();
} catch (error: any) { } catch (error: any) {
toast.error(error.response?.data?.message || 'Failed to save Etsy configuration'); toast.error(error.response?.data?.message || 'Failed to save Etsy configuration');
@ -266,6 +270,24 @@ const Settings = () => {
onChange={(e) => setApiKeyInput(e.target.value)} onChange={(e) => setApiKeyInput(e.target.value)}
/> />
</div> </div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Shared Secret
</label>
<input
type="password"
autoComplete="off"
placeholder={etsyConfig?.sharedSecretMasked
? `Saved (${etsyConfig.sharedSecretMasked}) — enter a new value to replace it`
: 'Paste the shared secret shown next to your keystring'}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-orange-500 focus:border-transparent"
value={sharedSecretInput}
onChange={(e) => setSharedSecretInput(e.target.value)}
/>
<p className="text-xs text-gray-500 mt-1">
Etsy requires this for API calls it's on the same page as your keystring.
</p>
</div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Callback URL Callback URL

View file

@ -15,4 +15,5 @@ SESSION_SECRET=your-super-secret-session-key-change-this-in-production
# Etsy API (optional fallback — normally configured per user in the # Etsy API (optional fallback — normally configured per user in the
# Settings UI and stored in the database) # Settings UI and stored in the database)
# ETSY_API_KEY=your-etsy-keystring # ETSY_API_KEY=your-etsy-keystring
# ETSY_SHARED_SECRET=your-etsy-shared-secret
# ETSY_REDIRECT_URI=https://etsy.plexultra.com/api/etsy/callback # ETSY_REDIRECT_URI=https://etsy.plexultra.com/api/etsy/callback

View file

@ -5,6 +5,9 @@ import mongoose, { Document, Schema } from 'mongoose';
export interface IEtsySettings extends Document { export interface IEtsySettings extends Document {
userId: mongoose.Types.ObjectId; userId: mongoose.Types.ObjectId;
apiKey: string; apiKey: string;
// Etsy requires the app's shared secret in the x-api-key header for API
// calls; the keystring (apiKey) is the OAuth client id
sharedSecret?: string;
redirectUri: string; redirectUri: string;
dateUpdated: Date; dateUpdated: Date;
} }
@ -12,6 +15,7 @@ export interface IEtsySettings extends Document {
const EtsySettingsSchema: Schema = new Schema({ const EtsySettingsSchema: Schema = new Schema({
userId: { type: Schema.Types.ObjectId, ref: 'User', required: true, unique: true, index: true }, userId: { type: Schema.Types.ObjectId, ref: 'User', required: true, unique: true, index: true },
apiKey: { type: String, required: true, trim: true }, apiKey: { type: String, required: true, trim: true },
sharedSecret: { type: String, trim: true },
redirectUri: { type: String, required: true, trim: true }, redirectUri: { type: String, required: true, trim: true },
dateUpdated: { type: Date, default: Date.now }, dateUpdated: { type: Date, default: Date.now },
}); });

View file

@ -30,6 +30,7 @@ const prunePending = () => {
interface EtsyConfig { interface EtsyConfig {
apiKey: string; apiKey: string;
sharedSecret?: string;
redirectUri: string; redirectUri: string;
} }
@ -37,11 +38,19 @@ interface EtsyConfig {
const loadConfig = async (userId: string): Promise<EtsyConfig | null> => { const loadConfig = async (userId: string): Promise<EtsyConfig | null> => {
const settings = await EtsySettings.findOne({ userId }); const settings = await EtsySettings.findOne({ userId });
const apiKey = settings?.apiKey || process.env.ETSY_API_KEY; const apiKey = settings?.apiKey || process.env.ETSY_API_KEY;
const sharedSecret = settings?.sharedSecret || process.env.ETSY_SHARED_SECRET;
const redirectUri = settings?.redirectUri || process.env.ETSY_REDIRECT_URI; const redirectUri = settings?.redirectUri || process.env.ETSY_REDIRECT_URI;
if (!apiKey || !redirectUri) return null; if (!apiKey || !redirectUri) return null;
return { apiKey, redirectUri }; return { apiKey, sharedSecret, redirectUri };
}; };
// Keystring is the OAuth client id; Etsy wants the shared secret in the
// x-api-key header for API resource calls
const toCredentials = (config: EtsyConfig) => ({
clientId: config.apiKey,
apiKeyHeader: config.sharedSecret || config.apiKey,
});
// Where to send the browser after the OAuth callback: same origin as the // Where to send the browser after the OAuth callback: same origin as the
// redirect URI (the app and API share one domain) // redirect URI (the app and API share one domain)
const settingsUrl = (redirectUri: string | undefined, suffix: string, reason?: string): string => { const settingsUrl = (redirectUri: string | undefined, suffix: string, reason?: string): string => {
@ -64,9 +73,11 @@ router.get('/config', authenticate, async (req: AuthRequest, res: Response) => {
try { try {
const settings = await EtsySettings.findOne({ userId: req.userId }); const settings = await EtsySettings.findOne({ userId: req.userId });
const apiKey = settings?.apiKey || process.env.ETSY_API_KEY; const apiKey = settings?.apiKey || process.env.ETSY_API_KEY;
const sharedSecret = settings?.sharedSecret || process.env.ETSY_SHARED_SECRET;
res.json({ res.json({
configured: Boolean(apiKey && (settings?.redirectUri || process.env.ETSY_REDIRECT_URI)), configured: Boolean(apiKey && (settings?.redirectUri || process.env.ETSY_REDIRECT_URI)),
apiKeyMasked: apiKey ? maskKey(apiKey) : null, apiKeyMasked: apiKey ? maskKey(apiKey) : null,
sharedSecretMasked: sharedSecret ? maskKey(sharedSecret) : null,
redirectUri: settings?.redirectUri || process.env.ETSY_REDIRECT_URI || null, redirectUri: settings?.redirectUri || process.env.ETSY_REDIRECT_URI || null,
source: settings ? 'database' : apiKey ? 'environment' : null, source: settings ? 'database' : apiKey ? 'environment' : null,
}); });
@ -77,7 +88,7 @@ router.get('/config', authenticate, async (req: AuthRequest, res: Response) => {
router.put('/config', authenticate, async (req: AuthRequest, res: Response) => { router.put('/config', authenticate, async (req: AuthRequest, res: Response) => {
try { try {
const { apiKey, redirectUri } = req.body || {}; const { apiKey, sharedSecret, redirectUri } = req.body || {};
if (typeof redirectUri !== 'string' || !redirectUri.trim()) { if (typeof redirectUri !== 'string' || !redirectUri.trim()) {
return res.status(400).json({ message: 'redirectUri is required' }); return res.status(400).json({ message: 'redirectUri is required' });
} }
@ -92,10 +103,14 @@ router.put('/config', authenticate, async (req: AuthRequest, res: Response) => {
if (!newKey) { if (!newKey) {
return res.status(400).json({ message: 'apiKey is required' }); return res.status(400).json({ message: 'apiKey is required' });
} }
const newSecret =
typeof sharedSecret === 'string' && sharedSecret.trim()
? sharedSecret.trim()
: existing?.sharedSecret;
await EtsySettings.findOneAndUpdate( await EtsySettings.findOneAndUpdate(
{ userId: req.userId }, { userId: req.userId },
{ userId: req.userId, apiKey: newKey, redirectUri: redirectUri.trim() }, { userId: req.userId, apiKey: newKey, sharedSecret: newSecret, redirectUri: redirectUri.trim() },
{ upsert: true, new: true, setDefaultsOnInsert: true } { upsert: true, new: true, setDefaultsOnInsert: true }
); );
@ -152,7 +167,7 @@ router.get('/callback', async (req: Request, res: Response) => {
if (!config) throw new Error('Etsy API key is no longer configured'); if (!config) throw new Error('Etsy API key is no longer configured');
const tokens = await exchangeCode(config.apiKey, code, pending.verifier, pending.redirectUri); const tokens = await exchangeCode(config.apiKey, code, pending.verifier, pending.redirectUri);
const identity = await fetchIdentity(config.apiKey, tokens.access_token); const identity = await fetchIdentity(toCredentials(config), tokens.access_token);
await EtsyConnection.findOneAndUpdate( await EtsyConnection.findOneAndUpdate(
{ userId: pending.userId }, { userId: pending.userId },
@ -198,7 +213,7 @@ router.post('/sync', authenticate, async (req: AuthRequest, res: Response) => {
const config = await loadConfig(req.userId!); const config = await loadConfig(req.userId!);
if (!config) return res.status(400).json({ message: 'Etsy API key is not configured' }); if (!config) return res.status(400).json({ message: 'Etsy API key is not configured' });
const result = await syncReceipts(config.apiKey, connection); const result = await syncReceipts(toCredentials(config), connection);
res.json(result); res.json(result);
} catch (err: any) { } catch (err: any) {
console.error('Etsy sync failed:', err); console.error('Etsy sync failed:', err);

View file

@ -10,6 +10,13 @@ const ETSY_CONNECT_URL = 'https://www.etsy.com/oauth/connect';
// Read scopes: receipts/transactions, listings, shop info // Read scopes: receipts/transactions, listings, shop info
export const ETSY_SCOPES = 'transactions_r listings_r shops_r'; export const ETSY_SCOPES = 'transactions_r listings_r shops_r';
// Etsy splits its credentials: the keystring is the OAuth client id, but API
// resource calls want the app's shared secret in the x-api-key header
export interface EtsyCredentials {
clientId: string;
apiKeyHeader: string;
}
// ---------- OAuth (authorization code + PKCE) ---------- // ---------- OAuth (authorization code + PKCE) ----------
export const generatePkce = () => { export const generatePkce = () => {
@ -67,10 +74,10 @@ const refreshTokens = (apiKey: string, refreshToken: string) =>
}); });
// Refresh the connection's access token if it expires within the next minute // Refresh the connection's access token if it expires within the next minute
const ensureFreshToken = async (apiKey: string, connection: IEtsyConnection): Promise<void> => { const ensureFreshToken = async (creds: EtsyCredentials, connection: IEtsyConnection): Promise<void> => {
if (connection.tokenExpiresAt.getTime() > Date.now() + 60_000) return; if (connection.tokenExpiresAt.getTime() > Date.now() + 60_000) return;
const tokens = await refreshTokens(apiKey, connection.refreshToken); const tokens = await refreshTokens(creds.clientId, connection.refreshToken);
connection.accessToken = tokens.access_token; connection.accessToken = tokens.access_token;
connection.refreshToken = tokens.refresh_token; connection.refreshToken = tokens.refresh_token;
connection.tokenExpiresAt = new Date(Date.now() + tokens.expires_in * 1000); connection.tokenExpiresAt = new Date(Date.now() + tokens.expires_in * 1000);
@ -79,11 +86,11 @@ const ensureFreshToken = async (apiKey: string, connection: IEtsyConnection): Pr
// ---------- API client ---------- // ---------- API client ----------
const etsyGet = async (apiKey: string, connection: IEtsyConnection, path: string): Promise<any> => { const etsyGet = async (creds: EtsyCredentials, connection: IEtsyConnection, path: string): Promise<any> => {
await ensureFreshToken(apiKey, connection); await ensureFreshToken(creds, connection);
const res = await fetch(`${ETSY_API_BASE}${path}`, { const res = await fetch(`${ETSY_API_BASE}${path}`, {
headers: { headers: {
'x-api-key': apiKey, 'x-api-key': creds.apiKeyHeader,
Authorization: `Bearer ${connection.accessToken}`, Authorization: `Bearer ${connection.accessToken}`,
}, },
}); });
@ -100,9 +107,9 @@ export interface EtsyIdentity {
} }
// Resolve the connected user's shop from a fresh access token // Resolve the connected user's shop from a fresh access token
export const fetchIdentity = async (apiKey: string, accessToken: string): Promise<EtsyIdentity> => { export const fetchIdentity = async (creds: EtsyCredentials, accessToken: string): Promise<EtsyIdentity> => {
const res = await fetch(`${ETSY_API_BASE}/application/users/me`, { const res = await fetch(`${ETSY_API_BASE}/application/users/me`, {
headers: { 'x-api-key': apiKey, Authorization: `Bearer ${accessToken}` }, headers: { 'x-api-key': creds.apiKeyHeader, Authorization: `Bearer ${accessToken}` },
}); });
if (!res.ok) { if (!res.ok) {
throw new Error(`Etsy users/me failed (${res.status}): ${await res.text()}`); throw new Error(`Etsy users/me failed (${res.status}): ${await res.text()}`);
@ -115,7 +122,7 @@ export const fetchIdentity = async (apiKey: string, accessToken: string): Promis
let shopName: string | undefined; let shopName: string | undefined;
try { try {
const shopRes = await fetch(`${ETSY_API_BASE}/application/shops/${me.shop_id}`, { const shopRes = await fetch(`${ETSY_API_BASE}/application/shops/${me.shop_id}`, {
headers: { 'x-api-key': apiKey, Authorization: `Bearer ${accessToken}` }, headers: { 'x-api-key': creds.apiKeyHeader, Authorization: `Bearer ${accessToken}` },
}); });
if (shopRes.ok) { if (shopRes.ok) {
const shop: any = await shopRes.json(); const shop: any = await shopRes.json();
@ -195,7 +202,7 @@ export interface SyncResult {
receiptsSeen: number; receiptsSeen: number;
} }
export const syncReceipts = async (apiKey: string, connection: IEtsyConnection): Promise<SyncResult> => { export const syncReceipts = async (creds: EtsyCredentials, connection: IEtsyConnection): Promise<SyncResult> => {
const products = await Product.find({ userId: connection.userId }); const products = await Product.find({ userId: connection.userId });
const result: SyncResult = { created: 0, updated: 0, unmatchedItems: [], receiptsSeen: 0 }; const result: SyncResult = { created: 0, updated: 0, unmatchedItems: [], receiptsSeen: 0 };
@ -204,7 +211,7 @@ export const syncReceipts = async (apiKey: string, connection: IEtsyConnection):
while (true) { while (true) {
const page = await etsyGet( const page = await etsyGet(
apiKey, creds,
connection, connection,
`/application/shops/${connection.shopId}/receipts?limit=${limit}&offset=${offset}` `/application/shops/${connection.shopId}/receipts?limit=${limit}&offset=${offset}`
); );