mcpField
S
salla-app-auth

salla-app-auth

github.com/sallaappSecurity290 installsUpdated 2w ago
Repository

About salla-app-auth

A guided skill for configuring Salla app OAuth and handling merchant tokens, aimed at app developers. It distinguishes webhook-based Easy Mode from callback-based Custom Mode and emphasizes durable storage and safe refresh-token rotation.

Key features

  1. 01Choose an OAuth mode
  2. 02Configure scopes and redirect URLs
  3. 03Receive or exchange merchant tokens
  4. 04Persist tokens by merchant
  5. 05Track access-token expiry
  6. 06Serialize refreshes to prevent races

Use cases

  1. 01Set up token delivery for a published app
  2. 02Test OAuth locally or with Postman
  3. 03Store merchant tokens for later API calls
  4. 04Refresh tokens without concurrent reuse

MCP support

The skill uses Salla Partners MCP tools to read and set OAuth scopes and connect app configuration. It guides developers through receiving or exchanging tokens; token handling itself is runtime code.

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/sallaapp/salla-partners-agent-kit --skill salla-app-auth

After installing, type/salla-app-authin Claude Code to confirm it loaded.

Bundled files

References and scripts installed along with SKILL.md.

references/
app-events.md
custom-mode.md
token-refresh.md

Details

From the project README

Salla App Auth Flow

Get and keep valid merchant tokens for your app — choose the OAuth mode, configure the app, receive/exchange tokens, and refresh them safely. Work through the steps in order; complete each gate before moving on. Step 2 performs actions with the Salla Partners MCP; the token handling is runtime code.

Publishing the app? → default to Easy Mode. Tokens arrive in the app.store.authorize webhook, so you don't need an OAuth /callback or state handling. Custom Mode (a /callback code exchange) is mainly for local dev / Postman; shipping it in a published app without a real, justifiable use case can be rejected at Salla's admin review — so reach for Easy Mode rather than a familiar OAuth2 callback out of habit.

Tools & MCPs

ToolActionWhat it does
salla_scopesget / setRead or update the app's OAuth scopes (slugs, disabled flags, selected)
salla_appsconnectSet scopes, redirect URLs, and the webhook_url (app.store.authorize auto-delivers to it) in one call

app.store.authorize is an app event — auto-delivered, no subscribe call. The app is subscribed to its own app events by default, so once a webhook_url is set the token event (app.store.authorize) and every other app.* lifecycle event arrive automatically. You do not call salla_events action=subscribe for it — that action is only for store events (order.*, product.*, …) → salla-webhooks.

Docs: https://docs.salla.dev/421118m0.md · App Events: https://docs.salla.dev/421413m0.md · API header: Authorization: Bearer <access_token>.


Step 0 — Discover

  1. Is this app going on the App Store (→ Easy Mode) or are you testing locally/Postman (→ Custom Mode)?
  2. Where will tokens be stored? Persist them in a real datastore (a DB) keyed by merchant id, with expiry. /tmp and in-memory are NOT storage — Step 3 covers why.
  3. Do you have a token-refresh concurrency story? (you will need one — Step 5)

Step 1 — Choose Your OAuth Mode

Easy Mode ✅Custom Mode
How tokens arriveVia app.store.authorize webhook payloadVia /oauth/callback code exchange
Callback URL needed?NoYes
Published apps?Recommended (default)Allowed with a justified use case — may be rejected at review
Allowed for testing?YesYes (Postman, local dev)
RecommendationDefault — recommended for every appDev only; needs a real use case
Token handlingSalla handles everything; you just saveYou implement the full exchange

Decision rule — default to Easy Mode. Easy Mode is the recommended default for every app — it's the more reliable and straightforward path and the easiest to implement for most use cases: Salla delivers the tokens via the app.store.authorize webhook, so there's no callback or state flow to build, secure, and maintain. Use it unless you have a concrete technical reason it cannot work. Custom Mode is for local dev / Postman during development; if you genuinely need it in production, be ready to justify the use case — a published app that ships Custom Mode without a real one can be rejected at review.

Gate: "Defaulted to Easy Mode (or have a real, reviewable reason for Custom Mode)?"


Step 2 — Configure the App (Partners MCP)

Set up the OAuth + webhook config that makes tokens flow. Do this with the Partners MCP:

  1. Scopes — read the slugs (+ per-app disabled flags) with salla_scopes action=get, app_id; update them with salla_scopes action=set (a flat slug → "read" | "read_write" | "" map) or as part of Connect below. Request least privilege: grant only the resource slugs the app actually uses, and prefer "read" over "read_write" unless the app writes that resource — don't apply a broad read_write default across slugs.

  2. Connect — salla_apps action=connect, app_id, with scopes ({ "<slug>": "read" | "read_write" } — slug and access level are separate keys, e.g. {"orders": "read_write"}). redirect_urls is the auth-mode selector, not just a URL registration:

    • Easy Mode — set redirect_urls to the app's easy_redirect_url (the Portal computes it as the Salla-owned callback; read it from salla_apps action=get). In production that's ["https://accounts.salla.sa/callback/{app_id}"]. Pointing the OAuth redirect_uri at Salla's own callback is what makes Salla own the exchange. Pair it with webhook_url + webhook_security_strategy: "signature".
    • Custom Mode — set redirect_urls to your own callback URL. Any non-Salla URL here activates Custom Mode — Salla redirects the merchant to your callback expecting a code exchange. So "don't build a callback" (Easy Mode) and "what you put in redirect_urls" are the same decision: in Easy Mode, point redirect_urls at the Salla callback, not your app.

    (Set trusted IPs here too — Part: IP whitelisting below.)

    The MCP never mints or rotates the signing secret — read it live before deploying. connect does not touch the webhook signing secret. Create or rotate it manually in the Partner Portal at https://portal.salla.partners/apps/{app_id} — give the user a ready link with the app's real id (e.g. https://portal.salla.partners/apps/1234567), not the {app_id} placeholder. Rotating there invalidates the old value. The MCP is read-only for it: before any deploy, read the current secret with salla_apps action=get (the webhook_secret field) and set that exact value as the verification env var. Never trust a secret remembered from an earlier session or before a context compaction — if it was rotated in the Portal it's stale, every X-Salla-Signature check fails, and tokens never land. (Signature verification itself → salla-webhooks.)

    offline_access does NOT go in the connect scopes map. It is an OAuth2 token scope that enables refresh tokens and belongs only in the authorize URL (space-delimited, e.g. scope=offline_access orders.read_write). The connect map takes resource slugs only (e.g. {"orders": "read_write"}).

  3. No subscribe call for the token event. app.store.authorize is an app event — with the webhook_url set in step 2 it auto-delivers, along with every other app.* lifecycle event. (Use salla_events action=subscribe only for store events your app reacts to → salla-webhooks.)

Manual fallback: Partners Portal → App Keys / Webhooks / App Scope.

Gate: "Resource scopes applied, and redirect_urls matches the intended mode — Easy Mode → redirect_urls = the app's easy_redirect_url (prod: ["https://accounts.salla.sa/callback/{app_id}"]) AND webhook_url + webhook_security_strategy set (app.store.authorize auto-delivers to it — no subscribe call; without a webhook_url the event has nowhere to land and tokens never arrive); Custom Mode → your own callback URL?"

Gate: "Before deploy: read the current webhook_secret via salla_apps action=get and set that exact value in the verification env — not a secret carried from an earlier session (it may have rotated)?"


Step 3 — Receive or Exchange Tokens

Easy Mode (production)
  1. Merchant installs the app → Salla fires app.store.authorize to your webhook.
  2. Your handler reads access_token + refresh_token from payload.data and saves both, keyed by merchant.
  3. When the merchant updates the app, Salla fires app.updated then app.store.authorize again — the same handler receives fresh tokens.

Handler shape: verify the signature first (salla-webhooks), then on app.store.authorize upsert access_token / refresh_token / expires * 1000 keyed by merchant, and return 200 immediately. Full handler code: references/app-events.md.

Persist tokens in a real datastore — /tmp and in-memory are NOT storage. Write the access/refresh tokens to a durable DB (Postgres, MySQL, DynamoDB, Redis with persistence, …) keyed by merchant. On serverless/Vercel, the filesystem (/tmp) and any module-level variable are ephemeral — wiped on every cold start: the token vanishes, the next API call 403s, and the merchant looks uninstalled even though they aren't. app.store.authorize fires once per install/update, so a lost token is not re-delivered — only a reinstall recovers it. Use real storage from the first commit.

Secret hygiene (both modes): access/refresh tokens and the client secret are secrets — store them encrypted at rest and never write them to logs, errors, or diagnostics. Redirect and webhook URLs are HTTPS-only. Restrict your app to known server IPs (IP whitelisting, below).

Easy Mode checklist: webhook URL set (Step 2) · app.store.authorize auto-delivers to it (app event — no subscribe call) · the granted data.scope in the payload contains offline_access (so refresh tokens are issued) · DB stores access_token / refresh_token / token_expires_at per merchant · handler upserts (not inserts).

Custom Mode (testing / local dev)

The authorization-code flow — authorize request (offline_access required, redirect_uri must match the Portal exactly) → callback (code + state) → POST /oauth2/token exchange → persist both tokens. It carries several Salla-specific callback traps (deploy the callback before registering it, Salla-initiated installs send their own state, hyphens stripped from state, single-use codes, the Next.js cookie-in-redirect trap).

Full flow with the authorize URL, token exchange, callback rules, and PHP/Laravel code: load references/custom-mode.md.

Gate: "Tokens are persisted per merchant with tokenExpiresAt derived from expires?"


Step 4 — Understand the Token Lifecycle

TokenLifetimeNotes
Access tokenPer the expires field (no fixed number)expires in the app.store.authorize payload is the source of truth — a Unix timestamp. Don't assume a fixed duration
Refresh tokenAlways valid (no expiry)Single-use per refresh — each refresh returns a new refresh token; save it. The token chain itself does not expire

expires is an absolute Unix timestamp (seconds), not a duration — drive expiry off it, never a hard-coded number. Convert before storing (Source: https://docs.salla.dev/421413m0.md):

// ✅ expires is an absolute Unix timestamp (seconds)
const expiresAt = new Date(payload.data.expires * 1000); // ms
// ❌ it is NOT a duration — never do: new Date(Date.now() + expires * 1000)

Refresh tokens are only issued when offline_access is in scope. Always include offline_access — without it, no refresh token is issued, so the access token cannot be renewed once expires passes and the merchant must reinstall.

Gate: "Both tokens + a converted expiresAt are stored, and scope includes offline_access?"


Step 5 — Refresh Tokens Safely (the danger zone)

Each refresh is single-use: it returns a fresh refresh token and kills the previous one. Using the same refresh token twice (a parallel-refresh race) makes Salla's OAuth server treat the chain as compromised — it revokes the chain and the merchant must reinstall, which is unrecoverable. Serialize refreshes with a per-merchant lock so a refresh token never leaves its lock without the new one being persisted.

Required: distributed mutex per merchant. Acquire a per-merchant lock before calling the token endpoint. If another process already holds it, wait briefly then re-read the now-refreshed token from the DB rather than retrying the refresh. Use a proven distributed-lock library (e.g. redlock for Redis, or a DB advisory lock) so owner-token and atomic release are handled for you. Refresh proactively (e.g. ~1 day before expiresAt), not on a 401, and always persist BOTH the new access and refresh tokens before releasing the lock — the old refresh token is dead the moment the call returns.

Full runnable code (TS refreshTokenSafe + getValidToken, and the PHP equivalent): load references/token-refresh.md.

Gate: "Refresh is guarded by a distributed lock, saves BOTH new tokens, and runs proactively before expiry?"


Step 6 — Fetch & Store Merchant Info

After obtaining a token (and after every app.store.authorize), refresh merchant details:

GET https://accounts.salla.sa/oauth2/user/info
Authorization: Bearer <access_token>
{
  "id": 1771165749,
  "name": "Test User",
  "email": "[email protected]",
  "merchant": {
    "id": 1803665367,
    "username": "dev-store-name",
    "name": "My Store",
    "plan": "special",
    "status": "active",
    "domain": "https://salla.sa/my-store"
  }
}

The store id is merchant.id — top level of the response, NOT under data. user/info has no data envelope (unlike webhooks/API responses). Extract defensively:

// ✅ user/info shape: { id, name, email, merchant: { id, ... } }
const merchantId = info?.merchant?.id; // number, top level — NOT info.data.merchant.id
if (!merchantId) throw new Error("user/info: missing merchant.id"); // guard BEFORE stringify
const storeId = String(merchantId);
// ❌ String(info?.merchant?.id ?? "") — turns a missing id into "" / a 0 id into "0", both
//    truthy after String(), so the guard silently passes and a bad value reaches the DB

Gate: "Merchant id + store details are stored alongside the tokens?"


Red Flags

Thoughts that feel reasonable in isolation but break a production Salla app. If you catch yourself thinking one of these, stop and re-read the named step.

Tempting thoughtWhy it's wrong
"I know OAuth — I'll just build the /callback flow."That's Custom Mode. Shipping it in a published app without a justified use case can be rejected at review. Default to Easy Mode (Step 1).
"The per-merchant refresh mutex is overkill."Single-use refresh tokens: a parallel double-use invalidates the whole token chain and the merchant must reinstall. Non-negotiable (Step 5).
"I'll add the distributed lock later / skip it in dev."Dev habits ship to prod, and the race only shows up under real concurrency — i.e. in production, on a real merchant. Add it once, now (Step 5).
"Refresh succeeded — I'll save the new access token."You must save both new tokens. The old refresh token is already dead; drop the new one and the next refresh fails (Step 5).
"offline_access is just another resource scope."It's an OAuth token scope and goes only in the authorize URL, never the connect map. Omit it and no refresh token is issued (Step 4).
"expires is how many seconds the token lasts."It's an absolute Unix timestamp. Treating it as a duration sets expiry decades out and the token silently dies (Step 4).
"I'll stash the token in /tmp (or a module variable) for now."On serverless the filesystem and memory are wiped on every cold start — the token vanishes and the next call 403s, so the merchant looks uninstalled. Persist to a real DB keyed by merchant from commit one (Step 3).
"I'll re-run connect to (re)mint the webhook secret before deploying."connect never mints or rotates the signing secret — the MCP is read-only for it. Create/rotate it in the Portal (https://portal.salla.partners/apps/{app_id}); before deploy read the current webhook_secret via salla_apps action=get and use that exact value (Step 2).
"I'll reuse the webhook secret I noted earlier."Rotating the secret in the Portal invalidates the old value, so a secret remembered from a past session or before a compaction may be stale → all verification fails. Read it live via salla_apps action=get before deploy (Step 2).
"Tokens in logs are fine for debugging."Access/refresh tokens and the client secret are secrets — encrypt at rest, never log them (Step 3).

Reference

OAuth scopes

There are two distinct scope contexts — do not mix them:

1. salla_apps action=connect scopes map — resource scopes only, slug + level as separate fields:

{ "orders": "read_write", "products": "read", "customers": "read_write" }

2. OAuth authorize URL — space-delimited dotted strings. Include offline_access here (it is an OAuth token scope that enables refresh tokens, not a resource scope):

scope=offline_access orders.read_write products.read customers.read_write

Confirm the app's valid resource slugs (and per-app disabled flags) via salla_scopes action=get:

orders          products        customers       branches
settings        webhooks        payments        taxes
specialoffers   categories      brands          metadata
App events
EventWhenAction
app.store.authorizeApp installed or updated (never on token refresh — that's your grant_type=refresh_token call)Save/update both tokens + expiry
app.installedFirst installProvision resources
app.uninstalledMerchant removes appClean up merchant data + revoke stored tokens

Full payload shapes: references/app-events.md. Lifecycle handling → salla-app-lifecycle.

IP whitelisting

Restrict your app to known server IPs: Partners Portal → My Apps → Your App → App Trusted IPs (or pass trusted_ips in salla_apps action=connect). Reduces attack surface for production apps.


Key Endpoints & Libraries

PurposeURL
Authorizationhttps://accounts.salla.sa/oauth2/auth
Token exchange + refreshhttps://accounts.salla.sa/oauth2/token
User infohttps://accounts.salla.sa/oauth2/user/info
Direct installhttps://s.salla.sa/apps/install/{app-id}
Salla API basehttps://api.salla.dev/admin/v2/
LibraryLanguageRepo
salla/oauth2-merchantPHPhttps://github.com/SallaApp/oauth2-merchant
@salla.sa/passport-strategyJavaScripthttps://github.com/SallaApp/passport-strategy
Laravel starter kitPHP/Laravelhttps://github.com/SallaApp/laravel-starter-kit

Comments

Markdown supported