--- url: https://docs.youcan.shop/apps/embedded_app/auth.md --- # Embedded App Authentication Embedded apps load inside the Seller Area as an iframe. There is no authorization code in the URL — the frontend calls `qantra.sessionToken()` at runtime to obtain a short-lived JWT, which your server exchanges for a store access token. ::: tip If you create your app using the [Nuxt Starter Template](https://github.com/youcan-shop/shop-app-template-nuxt) via the YouCan CLI, the entire auth flow (Qantra, JWT exchange, session persistence) is already fully implemented for you. ::: If you're building an external app that opens in its own browser tab, see [External App Authentication](/apps/external_app/auth) instead. ## Core Concepts | Term | Description | |---|---| | **Session ID** (`session`) | A stable, deterministic identifier for the (app × seller × store) combination. Passed in the launch URL as `?session=`. Used as your DB key — **not** an exchangeable credential. | | **Session Token** | A short-lived JWT signed by YouCan. Obtained at runtime via `qantra.sessionToken()` — it is **not** in the URL. This is what is exchanged for an access token. | | **Access Token** | A bearer token for the YouCan Store Admin API, offline or online. See [Token types](#token-types). | | **HMAC** | A SHA-256 signature appended to the launch URL, proving it came from YouCan. | | **Qantra** | YouCan's communication bridge between the Seller Area (parent window) and your app (iframe). | *** ## The Launch URL When a seller opens your embedded app, the Seller Area builds a signed URL: ``` https://your-app.com/?timestamp=...&session=SID&store=my-store&seller=ID&locale=en&embedded=1&host=bXktc3RvcmUueW91Y2FuLnN0b3JlL2FkbWlu&hmac=SIG ``` The `session` param is only a DB lookup key. The actual session JWT is fetched at runtime via `qantra.sessionToken()`. The store admin (`my-store.youcan.store/admin`) is sent base64-encoded in `host`. When your app is loaded outside the Seller Area, redirect to `https:///apps//app`. *** ## Step 1 — HMAC Verification **Always verify the HMAC before doing anything else.** An invalid HMAC means the request didn't come from YouCan. 1. Take all query params from the URL **except** `hmac`. 2. Build a query string (in the same order as received). 3. Compute `HMAC-SHA256(query_string, CLIENT_SECRET)`. 4. Compare using **constant-time comparison**. ::: warning Never use `==` or `===` to compare signatures. Use constant-time comparison to prevent timing attacks. ::: ```js const crypto = require('crypto'); function verifyHmac(queryString, clientSecret) { const params = new URLSearchParams(queryString); const received = params.get('hmac'); params.delete('hmac'); const message = params.toString(); const expected = crypto.createHmac('sha256', clientSecret).update(message).digest('hex'); const a = Buffer.from(received ?? '', 'hex'); const b = Buffer.from(expected, 'hex'); return a.length === b.length && crypto.timingSafeEqual(a, b); } ``` *** ## Step 2 — Obtain a Session Token via Qantra ### How `qantra.sessionToken()` works Qantra uses `postMessage` to communicate with the Seller Area, which calls its own internal API to generate and return the JWT: ```mermaid sequenceDiagram participant App as Your App (iframe) participant SA as Seller Area (parent window) participant API as YouCan API App->>SA: qantra.sessionToken() SA->>API: GET /apps/session-token (internal) API-->>SA: { session_token: JWT } SA->>App: resolves with JWT Note over App: Token ready for exchange ``` ### Auto-injection via `qantra.initialize()` Calling `qantra.initialize()` patches `window.fetch` so all same-origin requests automatically include: ```http Authorization: Bearer X-Requested-With: XMLHttpRequest ``` Your server reads the `Authorization: Bearer` header on every request — no manual token wiring needed. If your server responds with `x-youcan-retry-invalid-session-request`, Qantra calls `qantra.sessionToken()` again and retries the request once automatically. *** ## Step 3 — Exchange the Session Token ### Server middleware (Node.js / Express) ```js const jwt = require('jsonwebtoken'); const { exchangeSessionToken, db } = require('./lib'); async function sessionMiddleware(req, res, next) { const authHeader = req.headers['authorization']; if (!authHeader) return next(); const token = authHeader.replace('Bearer ', ''); const payload = jwt.verify(token, process.env.YOUCAN_API_SECRET, { audience: process.env.YOUCAN_API_KEY, }); if (payload.iss !== `${payload.dest}/admin`) { return res.status(401).end(); } let session = await db.session.findOne({ id: payload.sid }); if (!session) { session = await db.session.create({ id: payload.sid, store: payload.str }); } if (!session.accessToken) { const { access_token } = await exchangeSessionToken(token); session = await db.session.update({ id: payload.sid, accessToken: access_token }); } req.session = session; next(); } ``` ### Token exchange request ```js async function exchangeSessionToken(sessionToken, type = 'offline') { const res = await fetch('https://api.youcan.shop/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', client_id: process.env.YOUCAN_API_KEY, client_secret: process.env.YOUCAN_API_SECRET, subject_token: sessionToken, subject_token_type: 'urn:ietf:params:oauth:token-type:id_token', requested_token_type: `urn:youcan:params:oauth:token-type:${type}-access-token`, }), }); return res.json(); // { access_token, expires_in } } ``` | Parameter | Value | |---|---| | `grant_type` | `urn:ietf:params:oauth:grant-type:token-exchange` | | `subject_token` | The session token from `qantra.sessionToken()` | | `subject_token_type` | `urn:ietf:params:oauth:token-type:id_token` | | `requested_token_type` | `urn:youcan:params:oauth:token-type:offline-access-token` (default) or `urn:youcan:params:oauth:token-type:online-access-token` | `grant_type=token_exchange` with a `session_token` parameter is still accepted, with the same `requested_token_type`. ### Token types | | Offline | Online | |---|---|---| | Tied to | The store | The person who opened your app | | Lifetime | No expiry | 24 hours, no refresh token | | Scopes | Your app's scopes | Your app's scopes, cut down to the person's store permissions | | Use | Webhooks, background jobs, syncs | Requests made for the person in your app's UI | * You get the same offline token on every exchange for one store. * The offline token's scopes are updated to your app's current scopes on each exchange. * Do not revoke the offline token when a session ends. A revoked token cannot be used anywhere in your app until you exchange again. * A request with an online token outside the person's permissions is answered with `401`. You get the person in the response of an online exchange: ```json { "token_type": "Bearer", "expires_in": 86400, "access_token": "...", "associated_user_scope": "read-orders write-orders", "associated_user": { "id": "id_2Ada0Lovelace0Example0Id00", "first_name": "Ada", "last_name": "Lovelace", "email": "ada@example.com", "locale": "en", "account_owner": false } } ``` | Field | Description | |---|---| | `associated_user.id` | The person's account id. It is the same in every store. | | `associated_user.account_owner` | `true` when the person owns the store | | `associated_user_scope` | The token's scopes, after the person's permissions are applied | ### JWT Payload ```json { "iss": "https://my-store.youcan.store/admin", "dest": "https://my-store.youcan.store", "aud": "", "sub": "", "str": "", "sid": "", "jti": "", "scope": "read-orders edit-orders", "iat": 1709000000, "nbf": 1709000000, "exp": 1709000060 } ``` | Field | Use | |---|---| | `iss` | The store's admin URL. Must be `dest` followed by `/admin`. | | `dest` | The store's YouCan URL (`https://.youcan.store`). A custom domain of the store is never used here. | | `aud` | Must match your Client ID | | `sub` | The id of the seller who opened your app | | `str` | The store ID | | `sid` | Session ID. Use it as your DB primary key. | | `scope` | Your app's scopes, separated by spaces | | `nbf` | Reject the token before this time. | | `exp` | Reject expired tokens. Session tokens are valid for 1 minute. | *** ## Full Flow Diagram ```mermaid sequenceDiagram actor seller participant SA as Seller Area participant App as Your App (iframe) participant Server as Your App Server participant API as YouCan API seller->>SA: Opens app SA->>SA: Build URL with session=SID, embedded=1, HMAC SA->>App: Load iframe at https://app.com/?session=SID&hmac=SIG App->>Server: GET / (signed URL) Server->>Server: Verify HMAC Server->>Server: Upsert session (SID), reset accessToken Server-->>App: Render page + load JS Note over App,SA: qantra.initialize() patches fetch, requests session token App->>SA: qantra.sessionToken() SA->>API: GET /apps/session-token API-->>SA: JWT SA->>App: resolves with JWT App->>Server: GET /api/data (Bearer JWT auto-injected by Qantra) Server->>Server: Verify JWT, look up session alt No access_token stored Server->>API: POST /oauth/token (token_exchange) API-->>Server: access_token Server->>Server: Persist access_token end Server->>API: GET /orders (Bearer access_token) API-->>Server: Data Server-->>App: Response ``` *** ## Persisting Sessions ### Recommended schema ```sql CREATE TABLE sessions ( id TEXT PRIMARY KEY, -- JWT `sid` store TEXT NOT NULL, -- store id (JWT `str`) access_token TEXT -- null until first exchange ); ``` ### On re-install or re-open When the HMAC-verified launch URL is processed, always reset the access token: ```js async function handleSessionPost(sessionId, store) { const existing = await db.session.findOne({ id: sessionId }); if (existing) { // Reset to force fresh token exchange (handles reinstalls) } else { await db.session.create({ id: sessionId, store }); } } ``` *** ## Re-authentication & Token Recovery | Scenario | Action | |---|---| | Seller opens embedded app | `qantra.sessionToken()` JWT injected → server looks up session → reuses stored token | | No access token in DB | JWT present → server does `token_exchange` → persists token | | YouCan API returns `401` | Clear `access_token` in DB. Qantra retries with fresh JWT from `qantra.sessionToken()`. | | App uninstalled | `app.uninstalled` webhook → clear all access tokens for the store |