# Auth (/apps/auth)

| Credential           | Holder              | Use                                                                                                                                                 |
| -------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| App access token     | Your server         | `PolturaClient` bearer for the App API (`/apps-api/v1`), with `Poltura-Installation`                                                                |
| Merchant session JWT | RAM for one request | Prove which merchant is looking at a hosted iframe. Not an API bearer. JWT type `poltura-app-session+jwt`, valid for 60 seconds, at most 4096 bytes |

## Permissions [#permissions]

An app asks for permission ids, the same permissions merchants give their team members. The permissions the
merchant granted are in `installation.retrieve().permissions`. Required permissions are always there; optional
ones the merchant turned down are missing. A call that needs a permission the installation lacks fails with
`403 forbidden`: tell the merchant which permission the feature needs instead of retrying. The App API uses these:

| Permission        | App API access                                                                                                         |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `settings.read`   | `GET /store`: the store name, locale, currency, and public capabilities.                                               |
| `products.read`   | Products, collections, and inventory levels.                                                                           |
| `products.write`  | Product title, description, handle, status, and metadata; collection title, handle, and metadata. Not prices or stock. |
| `orders.read`     | Order status, totals, items, and shipping address.                                                                     |
| `orders.write`    | Order metadata, tags, and note. Not status, payment, or fulfillment.                                                   |
| `customers.read`  | Customer name, email, phone, tags, and metadata.                                                                       |
| `customers.write` | Customer metadata and tags. Not account status.                                                                        |

`GET /installation` and installation storage need no permission: storage holds the installation's own data, not the merchant's. Every permission an app may request is listed in [Permissions and topics](/apps/permissions), and the full catalogue with labels and descriptions is served at `GET https://api.poltura.com/v1/access/catalog?locale=en` (or `locale=hu`). The `purpose` on each permission in the manifest is 8 to 280 characters and is what the merchant sees.

## Credentials [#credentials]

In **Developer apps → Credentials**, register one or more public keys of your own (RSA 2048+ with
`RS256`, or EC P-256 with `ES256`) as a JWKS. The private keys never leave your servers. Poltura ID
answers with:

- the app's **client id**, for calls a person makes through your app;
- the app's **machine user id** and, for each key, the **key id** your JWT profile assertions name.

Rotate by registering the new key next to the old one, deploying, then removing the old one.

## Installing [#installing]

A merchant installs your app in Poltura ID at `https://id.poltura.com/install?app=<app id>`. Link
there from your onboarding. Someone who may install apps chooses the stores (a list, or every store
including future ones), sees the required permissions locked with your purposes, and turns on any
optional ones. Nobody can give your app more than they hold themselves. The installation then has
one **installation id** (a UUID such as `0199f3c4-2a6e-7f81-b3d5-6e7f8091a2b3`) per store; `app.installed` and `GET /installation` tell you
which.

## Calling as your app [#calling-as-your-app]

Your server acts as the app itself with the JWT profile (RFC 7523): it signs an assertion for its
machine user and exchanges it at `https://id.poltura.com/oauth/v2/token`. Every App API call names
the store installation in `Poltura-Installation`; the installation's permissions decide.



```ts
import { PolturaClient } from '@poltura/app-sdk';
import { createAppTokenSource } from '@poltura/app-sdk/oauth';

const tokens = createAppTokenSource({
  issuer: 'https://id.poltura.com',
  userId: process.env.POLTURA_MACHINE_USER_ID,
  keyId: process.env.POLTURA_KEY_ID,
  privateKey: privateJwk,
});
const client = new PolturaClient({
  origin: 'https://api.poltura.com',
  accessToken: await tokens.accessToken(),
  installationId: '0199f3c4-2a6e-7f81-b3d5-6e7f8091a2b3',
});
```

## Calling for a person [#calling-for-a-person]

To act for the person using your app, run the authorization code flow with PKCE against Poltura ID
with `createPublicAppOAuthClient`, authenticating with your own key (`private_key_jwt`). Ask for the
permission ids you need, within your manifest's required and optional ones. The person consents in
Poltura ID. Such a call may do only what the installation, the person's grant and the person's
current role all allow, and the store's audit names the person through your app.

## Refusals and changes [#refusals-and-changes]

- `403` with `error.code: "insufficient_scope"` names the `permission` the call needed. For a person
  whose grant lacks a permission the installation holds, `WWW-Authenticate` lists the scope to ask
  for again; anything the installation lacks waits for a merchant's approval.
- A release that adds required permissions deploys at once; the installation keeps what it was given
  until an administrator approves the new ones in Poltura ID. Permissions a release drops are
  removed at once. Optional permissions are never prompted; ask a person through consent.
- `installation.permissions_changed` and `installation.stores_changed` tell you to read
  `GET /installation` again: it returns the stores, the permissions this call may use and the
  pending ones.
- Uninstalling revokes the installation and every grant a person gave your app in that
  organisation; old tokens stop working at once.

## Session tokens for hosted pages [#session-tokens-for-hosted-pages]

A [hosted page](/apps/admin#hosted-iframe-pages) runs in an iframe in the admin. When it calls your server, your
server needs to know which merchant and which installation it is talking to. A cookie cannot tell it: the page
is on your origin inside Poltura's, and browsers block such cookies. The page asks the admin for a **session
token** instead and sends it with each request.

A session token is a signed statement from Poltura: this merchant has this installation of this app open right
now. It lasts at most 60 seconds, carries no permissions and is **not** an API token: the App API refuses it.
Your server uses it only to pick the stored installation token.

1. Add `get_session_token` to `adminFrame.actions` in the [manifest](/apps/manifest#adminframe).
2. In the page, connect to the admin and request a token. The SDK holds it in memory only:



```ts
const session = connectAppFrame({
  window,
  parentWindow: window.parent,
  parentOrigin: root.dataset.parentOrigin ?? '',
  approvedFrameOrigin: root.dataset.frameOrigin ?? '',
  actions: ['get_session_token'],
});
try {
  await ready(session);
  const { token } = await requestAppSession(session);
  const response = await fetch('/api/overview', {
    headers: { authorization: `Bearer ${token}` },
    credentials: 'omit',
  });
```

`parentOrigin` is the Poltura admin origin and `approvedFrameOrigin` your own; the example's server writes both
into the page. `requestAppSession` throws `AppFrameSessionError` before the admin has connected, when the
manifest does not list `get_session_token`, or after the frame is gone.

3. On your server, create one verifier at start-up:



```ts
const sessions = createAppSessionVerifier({
  issuer: env.POLTURA_SESSION_ISSUER,
  audience: appOrigin,
  appId: env.POLTURA_CLIENT_ID,
  jwks: JSON.parse(env.POLTURA_SESSION_JWKS),
  oauthIssuer: env.POLTURA_ISSUER,
  apiAudience: env.POLTURA_RESOURCE,
  merchantIssuer: env.POLTURA_MERCHANT_ISSUER,
});
```

`audience` is your page's origin. `jwks` is the public key set from the session issuer; the SDK never fetches
keys on its own, so reload it when Poltura adds a key. `oauthIssuer`, `apiAudience` and `merchantIssuer` must be
set and must all differ from each other and from the session issuer, so a token meant for one cannot pass as
another.

4. Verify each request, then match it to the installation you stored at install time:



```ts
const header = req.headers.authorization ?? '';
const token = header.startsWith('Bearer ') ? header.slice('Bearer '.length) : '';
const session = await sessions.verify(token);
const connection = connections.get(session.installationId);
const bound =
  connection !== undefined &&
  connection.installation.appId === session.appId &&
  connection.installation.storeId === session.storeId &&
  connection.installation.releaseId === session.releaseId &&
  connection.installation.generation === session.generation;
const reconnect = { connectUrl: `${appOrigin}/connect?installation=${session.installationId}` };
if (connection === undefined || !bound) {
  json(res, 409, reconnect);
  return;
}
```

`verify` checks the ES256 signature, the `poltura-app-session+jwt` type, the issuer, the audience, your app id
and a lifetime of at most 60 seconds (with 5 seconds of clock tolerance), and refuses tokens over 4096 bytes.
On any failure it throws `AppSessionError` without details. On success it returns the merchant (`subject`),
`appId`, `storeId`, `installationId`, `releaseId`, `generation`, `revision`, `manifestDigest`, `frameId`, `jti`
and the times.

- A valid signature says the merchant had the page open; it does not prove the installation is still active.
  Before anything sensitive, call the App API with the stored token, which fails once the app is removed.
- A token can be sent again until it expires. If an operation must happen once, record its `jti`.
- If you have no stored installation yet, the page cannot fix that from inside the iframe: it cannot open
  pop-ups or navigate the admin. The example answers `409` with the connect address, and the page asks the
  merchant to open it in a new tab.

## Signed actions [#signed-actions]

A native worker calls your server through an [action](/apps/manifest#actions): `useAction('suggest_box')` in the
worker becomes a `POST` to `backendOrigin` plus the action's path, signed by Poltura. Verify it with
`createActionHandler` from `@poltura/app-sdk/actions`:



```ts
const actions = createActionHandler({
  trustedKeys: [{ kid: env.POLTURA_WEBHOOK_KID, publicKeyPem: env.POLTURA_WEBHOOK_PUBLIC_KEY }],
  handlers: {
    suggest_box(request) {
      return suggestBox(SuggestBoxInput.parse(request.input).quantity);
    },
    check_listing(request) {
      return { hints: listingHints(CheckListingInput.parse(request.input)) };
    },
  },
});
```

`actions.handle(rawBody, headers)` verifies the request, runs the handler named by the request and returns its
JSON answer. Pass the body exactly as received, as bytes; parsing it first breaks the signature.

| Header                                  | Meaning                                                                                  |
| --------------------------------------- | ---------------------------------------------------------------------------------------- |
| `poltura-signature`                     | `v1=` and an Ed25519 signature, base64url without padding, over `<timestamp>.<raw body>` |
| `poltura-timestamp`                     | Unix seconds when Poltura signed the request                                             |
| `poltura-action`                        | The action id; picks the handler                                                         |
| `poltura-installation`, `poltura-store` | The installation and store; must match the body                                          |
| `poltura-surface`                       | `admin` or `storefront`; must match the body                                             |
| `poltura-request-id`                    | A unique id for this call                                                                |
| `poltura-shopper`                       | Present on storefront calls                                                              |

The body is `{ "input": ..., "context": { "installationId", "storeId", "surface", "locale", "instanceId", ... } }`,
at most 64 KiB. `request.input` is whatever the worker passed; validate it like any other input.

- **Timestamps** more than 300 seconds old or ahead are refused (`expired_timestamp`, `future_timestamp`).
- **Replays** inside that window carry a valid signature. If an action changes something, store
  `poltura-request-id` and ignore a second request with the same id.
- **Keys**: actions are signed with the same platform key as [webhooks](/apps/webhooks). Pass 1 to 8 public
  keys; during a rotation, trust the old and the new one.
- **Errors** are `PolturaActionError` with a `code`: `invalid`, `wrong_algorithm`, `unknown_kid`,
  `expired_timestamp`, `future_timestamp`, `noncanonical_signature`, `payload_too_large`, `altered_body` or
  `not_declared` (no handler for that action). Answer them with a `4xx`; the worker's call then fails.

## Signed webhooks [#signed-webhooks]

Webhooks use the same signature: Ed25519 over `<timestamp>.<raw body>`, a 300-second window and the platform
signing key, picked by the `poltura-key-id` header. `createWebhookVerifier` from `@poltura/app-sdk/webhooks`
checks them and can also require one store and installation. Details, topics and retries are on
[Webhooks](/apps/webhooks).

## What the browser never gets [#what-the-browser-never-gets]

- **Access and refresh tokens.** Only your server holds them. Never send one to a worker, a hosted page or a URL,
  not even to work around a blocked cookie.
- **Your private signing key** and the authorization state. The browser carries only the redirect address and an
  opaque cookie that points at the state on your server.
- **Session tokens** stay in the page's memory for one request: not in cookies, `localStorage`, URLs or logs.
- **Workers** get none of the above. They reach your server through actions, which Poltura signs and sends for
  them, and read store data only through the hooks the platform gives them.

## Next [#next]

- [Webhooks](/apps/webhooks): topics, payloads and retries.
- [App API](/api/app): every route and the permission it needs.
- [Examples](/apps/examples): the hosted example runs this whole page.
