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:
parent
08160775e7
commit
4333b1c55d
5 changed files with 64 additions and 15 deletions
|
|
@ -16,6 +16,7 @@ interface EtsyStatus {
|
|||
interface EtsyConfig {
|
||||
configured: boolean;
|
||||
apiKeyMasked: string | null;
|
||||
sharedSecretMasked: string | null;
|
||||
redirectUri: string | null;
|
||||
source: 'database' | 'environment' | null;
|
||||
}
|
||||
|
|
@ -33,6 +34,7 @@ const Settings = () => {
|
|||
const [etsyStatus, setEtsyStatus] = useState<EtsyStatus | null>(null);
|
||||
const [etsyConfig, setEtsyConfig] = useState<EtsyConfig | null>(null);
|
||||
const [apiKeyInput, setApiKeyInput] = useState('');
|
||||
const [sharedSecretInput, setSharedSecretInput] = useState('');
|
||||
const [redirectUriInput, setRedirectUriInput] = useState('');
|
||||
const [isSavingConfig, setIsSavingConfig] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
|
|
@ -90,10 +92,12 @@ const Settings = () => {
|
|||
try {
|
||||
await api.put('/etsy/config', {
|
||||
apiKey: apiKeyInput.trim() || undefined,
|
||||
sharedSecret: sharedSecretInput.trim() || undefined,
|
||||
redirectUri: redirectUriInput.trim(),
|
||||
});
|
||||
toast.success('Etsy configuration saved');
|
||||
setApiKeyInput('');
|
||||
setSharedSecretInput('');
|
||||
loadEtsyConfig();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Failed to save Etsy configuration');
|
||||
|
|
@ -266,6 +270,24 @@ const Settings = () => {
|
|||
onChange={(e) => setApiKeyInput(e.target.value)}
|
||||
/>
|
||||
</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>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Callback URL
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# Settings UI and stored in the database)
|
||||
# ETSY_API_KEY=your-etsy-keystring
|
||||
# ETSY_SHARED_SECRET=your-etsy-shared-secret
|
||||
# ETSY_REDIRECT_URI=https://etsy.plexultra.com/api/etsy/callback
|
||||
|
|
@ -5,6 +5,9 @@ import mongoose, { Document, Schema } from 'mongoose';
|
|||
export interface IEtsySettings extends Document {
|
||||
userId: mongoose.Types.ObjectId;
|
||||
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;
|
||||
dateUpdated: Date;
|
||||
}
|
||||
|
|
@ -12,6 +15,7 @@ export interface IEtsySettings extends Document {
|
|||
const EtsySettingsSchema: Schema = new Schema({
|
||||
userId: { type: Schema.Types.ObjectId, ref: 'User', required: true, unique: true, index: true },
|
||||
apiKey: { type: String, required: true, trim: true },
|
||||
sharedSecret: { type: String, trim: true },
|
||||
redirectUri: { type: String, required: true, trim: true },
|
||||
dateUpdated: { type: Date, default: Date.now },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ const prunePending = () => {
|
|||
|
||||
interface EtsyConfig {
|
||||
apiKey: string;
|
||||
sharedSecret?: string;
|
||||
redirectUri: string;
|
||||
}
|
||||
|
||||
|
|
@ -37,11 +38,19 @@ interface EtsyConfig {
|
|||
const loadConfig = async (userId: string): Promise<EtsyConfig | null> => {
|
||||
const settings = await EtsySettings.findOne({ userId });
|
||||
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;
|
||||
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
|
||||
// redirect URI (the app and API share one domain)
|
||||
const settingsUrl = (redirectUri: string | undefined, suffix: string, reason?: string): string => {
|
||||
|
|
@ -64,9 +73,11 @@ router.get('/config', authenticate, async (req: AuthRequest, res: Response) => {
|
|||
try {
|
||||
const settings = await EtsySettings.findOne({ userId: req.userId });
|
||||
const apiKey = settings?.apiKey || process.env.ETSY_API_KEY;
|
||||
const sharedSecret = settings?.sharedSecret || process.env.ETSY_SHARED_SECRET;
|
||||
res.json({
|
||||
configured: Boolean(apiKey && (settings?.redirectUri || process.env.ETSY_REDIRECT_URI)),
|
||||
apiKeyMasked: apiKey ? maskKey(apiKey) : null,
|
||||
sharedSecretMasked: sharedSecret ? maskKey(sharedSecret) : null,
|
||||
redirectUri: settings?.redirectUri || process.env.ETSY_REDIRECT_URI || 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) => {
|
||||
try {
|
||||
const { apiKey, redirectUri } = req.body || {};
|
||||
const { apiKey, sharedSecret, redirectUri } = req.body || {};
|
||||
if (typeof redirectUri !== 'string' || !redirectUri.trim()) {
|
||||
return res.status(400).json({ message: 'redirectUri is required' });
|
||||
}
|
||||
|
|
@ -92,10 +103,14 @@ router.put('/config', authenticate, async (req: AuthRequest, res: Response) => {
|
|||
if (!newKey) {
|
||||
return res.status(400).json({ message: 'apiKey is required' });
|
||||
}
|
||||
const newSecret =
|
||||
typeof sharedSecret === 'string' && sharedSecret.trim()
|
||||
? sharedSecret.trim()
|
||||
: existing?.sharedSecret;
|
||||
|
||||
await EtsySettings.findOneAndUpdate(
|
||||
{ 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 }
|
||||
);
|
||||
|
||||
|
|
@ -152,7 +167,7 @@ router.get('/callback', async (req: Request, res: Response) => {
|
|||
if (!config) throw new Error('Etsy API key is no longer configured');
|
||||
|
||||
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(
|
||||
{ userId: pending.userId },
|
||||
|
|
@ -198,7 +213,7 @@ router.post('/sync', authenticate, async (req: AuthRequest, res: Response) => {
|
|||
const config = await loadConfig(req.userId!);
|
||||
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);
|
||||
} catch (err: any) {
|
||||
console.error('Etsy sync failed:', err);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,13 @@ const ETSY_CONNECT_URL = 'https://www.etsy.com/oauth/connect';
|
|||
// Read scopes: receipts/transactions, listings, shop info
|
||||
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) ----------
|
||||
|
||||
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
|
||||
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;
|
||||
|
||||
const tokens = await refreshTokens(apiKey, connection.refreshToken);
|
||||
const tokens = await refreshTokens(creds.clientId, connection.refreshToken);
|
||||
connection.accessToken = tokens.access_token;
|
||||
connection.refreshToken = tokens.refresh_token;
|
||||
connection.tokenExpiresAt = new Date(Date.now() + tokens.expires_in * 1000);
|
||||
|
|
@ -79,11 +86,11 @@ const ensureFreshToken = async (apiKey: string, connection: IEtsyConnection): Pr
|
|||
|
||||
// ---------- API client ----------
|
||||
|
||||
const etsyGet = async (apiKey: string, connection: IEtsyConnection, path: string): Promise<any> => {
|
||||
await ensureFreshToken(apiKey, connection);
|
||||
const etsyGet = async (creds: EtsyCredentials, connection: IEtsyConnection, path: string): Promise<any> => {
|
||||
await ensureFreshToken(creds, connection);
|
||||
const res = await fetch(`${ETSY_API_BASE}${path}`, {
|
||||
headers: {
|
||||
'x-api-key': apiKey,
|
||||
'x-api-key': creds.apiKeyHeader,
|
||||
Authorization: `Bearer ${connection.accessToken}`,
|
||||
},
|
||||
});
|
||||
|
|
@ -100,9 +107,9 @@ export interface EtsyIdentity {
|
|||
}
|
||||
|
||||
// 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`, {
|
||||
headers: { 'x-api-key': apiKey, Authorization: `Bearer ${accessToken}` },
|
||||
headers: { 'x-api-key': creds.apiKeyHeader, Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
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;
|
||||
try {
|
||||
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) {
|
||||
const shop: any = await shopRes.json();
|
||||
|
|
@ -195,7 +202,7 @@ export interface SyncResult {
|
|||
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 result: SyncResult = { created: 0, updated: 0, unmatchedItems: [], receiptsSeen: 0 };
|
||||
|
||||
|
|
@ -204,7 +211,7 @@ export const syncReceipts = async (apiKey: string, connection: IEtsyConnection):
|
|||
|
||||
while (true) {
|
||||
const page = await etsyGet(
|
||||
apiKey,
|
||||
creds,
|
||||
connection,
|
||||
`/application/shops/${connection.shopId}/receipts?limit=${limit}&offset=${offset}`
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue