# Portal SSO

Portal SSO lets your end-customers sign in to the LedgerBee customer portal
through your own login, and lets your backend provision net-new customers on
demand. Your operator configures SSO (OIDC, back-channel handoff, claim
mapping) under **Portal → Single sign-on** and hands you a Partner SSO API key
for the **Portal SSO** endpoints in the [API Reference](/api).

## The Partner SSO key and its scopes

Partner calls authenticate with the `x-api-key` header — see
[Authentication](/guides/authentication). The tenant is resolved server-side
from the key; never put a tenant id in a request body.

| Scope | Authorizes |
|---|---|
| `portal-provision` | `POST /v1/portal-sso/provision` |
| `portal-sso-mint` | `POST /v1/portal-sso/handoff/mint`, `POST /v1/portal-sso/sessions/revoke`, and the same-call handoff mint inside provision |

These scopes cannot be assigned through the generic API-key or OAuth-client
paths (`API_KEY_SCOPE_NOT_ASSIGNABLE`); the operator mints the key via
**Generate SSO key** on the SSO tab, and the plaintext is shown once.

The tenant must also hold the CustomerPortal license. Without it, every
Portal SSO endpoint returns `403` with `code: "LICENSE.REQUIRED"` and
`details.licenseKey: "CustomerPortal"`, regardless of the key's scopes — see
[Errors](/guides/errors).

## Provision a customer

`POST /v1/portal-sso/provision` (scope `portal-provision`) upserts a customer
and grants a credential-less portal user an active membership in one idempotent
call, so a net-new end-customer can arrive via SSO. No invitation email is sent.

Identify the customer with exactly one of:

- `customerId` — an existing customer, never mutated.
- `customer` — an idempotent upsert keyed on `customer.customerNumber`
  within your tenant: an existing match is reused as-is; otherwise a customer is
  created. Required when creating: `customerType`, `customerNumber`, `name`,
  `countryCode`, `vatZone` (lowercase: `domestic`, `eu`, `abroad`,
  `domestic_without_vat`), and `customerGroupId` (list your tenant's groups
  under the **Customers** tag in the [API Reference](/api)).

```bash
curl -X POST 'https://api.ledgerbee.com/api/v1/portal-sso/provision' \
  -H 'x-api-key: <your-partner-sso-key>' \
  -H 'Content-Type: application/json' \
  -d '{
        "customer": {
          "customerType": "BUSINESS",
          "customerNumber": "ACME-001",
          "name": "Acme A/S",
          "countryCode": "DK",
          "vatZone": "domestic",
          "customerGroupId": "550e8400-e29b-41d4-a716-446655440099"
        },
        "email": "user@example.com"
      }'
```

The top-level `email` (required) is the portal-user login. It is distinct from
`customer.email`, the customer's contact email, which falls back to it when
omitted. The response carries `customerId`, `customerCreated`, `portalUserId`,
`membershipId`, and `role`. The organization's first member is force-assigned
`OWNER`; re-provisioning reactivates a previously-removed membership.

`mintHandoff: true` additionally returns `handoffRef` / `handoffExpiresAt` for
a single round-trip "Go to portal". It requires the `portal-sso-mint` scope,
handoff enabled on the tenant, and `sub`.

## Mint a handoff reference

The back-channel handoff signs a user in without a front-channel JWT: you
authenticate the user, mint a single-use opaque `ref` (the identity stays
server-side), and redirect the browser to the portal to redeem it.

`POST /v1/portal-sso/handoff/mint` (scope `portal-sso-mint`) responds with
`{ ref, expiresAt }`:

```bash
curl -X POST 'https://api.ledgerbee.com/api/v1/portal-sso/handoff/mint' \
  -H 'x-api-key: <your-partner-sso-key>' \
  -H 'Content-Type: application/json' \
  -d '{
        "email": "user@example.com",
        "sub": "your-stable-user-id",
        "memberships": [{ "customerId": "your-customer-ref", "role": "USER" }]
      }'
```

- `email`, `sub` (your stable user id), and `memberships` are required.
- At least one membership is required. The portal user, organization, and
  membership are created at redeem from what you assert; no prior provision
  call is needed. Each `customerId` must be a real customer of your tenant.
- `role` is one of `OWNER`, `ADMIN`, `BILLING_ADMIN`, `USER`.
- When asserting multiple memberships, mark exactly one with
  `"primary": true` — the organization the user is signed into. With a single
  membership the flag is optional.

## Redeem the handoff

Return the user's browser to the portal-API host — not `api.ledgerbee.com`
where you minted, and not the portal SPA host (`<slug>.portal.…`), which serves
the static app and ignores the ref:

```
GET https://<slug>.portal-api.ledgerbee.com/api/auth/sso/handoff/redeem?ref=<ref>&returnTo=<relative-path>
```

The redeem resolves or creates the portal user, sets the session cookie
(1-hour `maxAge`), and redirects to the portal SPA plus `returnTo` (same-origin
relative paths only). The ref has a 60-second TTL and is consumed atomically.
Any failure (missing, expired, or replayed ref; no resolvable membership)
redirects to the tenant login screen with `?ssoError=1` rather than a raw
error body. Mint a fresh ref.

## Revoke sessions (offboarding)

`POST /v1/portal-sso/sessions/revoke` (scope `portal-sso-mint`, not
`portal-provision`) with `{ "sub": "your-stable-user-id" }` returns
`{ revoked: <count> }`. It immediately invalidates the user's live portal
sessions but does not prevent re-login — deprovision the user at your IdP for
that. An unknown `sub` returns `404`; `revoked: 0` means no active sessions.

## OIDC login

OIDC login is the front-channel alternative: the portal redirects the browser
to your OpenID Provider (OP), validates the returned ID token, and mints the
session. You host the OP and emit the claims the operator configured.

- Register exactly the redirect URI shown on the operator's SSO tab
  (`https://<slug>.portal-api.<zone>/api/auth/sso/oidc/callback`); it is derived
  from the portal slug and not editable.
- The ID token must pass: exact `iss` match, `aud` equal to the configured
  client id, unexpired, JWS signature verifiable via your published JWKS, and
  the `nonce` minted at start. `email` falls back to `preferred_username`; the
  login fails if both are absent.
- PKCE `S256` is always sent — accept it even if your OP does not advertise
  `code_challenge_methods_supported`.
- LedgerBee always requests `openid email profile`; operator-configured scopes
  are additive. If your IdP gates role/customer claims behind a custom scope
  (Okta's `groups`, an Entra API scope), the operator must add it — or the
  membership mapping comes back empty.

### Claim mapping

Validated claims map to memberships of `{ customerId, role }` — `customerId` is
your asserted customer id, not a LedgerBee-internal id:

- **Single-org** — `orgClaimName` (customer id) + `roleClaimName` (role), both
  string-typed.
- **Multi-org** — `membershipsClaimName`: a JSON array of
  `{ customerId, role }`. A non-empty array supersedes the single-org pair.
- An unmapped role or a `customerId` not in the tenant fails the login closed.
  With no usable claims, the login succeeds and memberships are untouched.
  Asserted memberships reconcile on each login — dropped ones are deactivated,
  not deleted.

### JWKS — verifying LedgerBee's client assertions

Under `private_key_jwt` client auth, your OP fetches LedgerBee's public signing
keys from a public, version-neutral endpoint (no `/v1` segment):

```
GET https://api.ledgerbee.com/api/.well-known/portal-sso/<slug>/jwks.json
```

It returns an RFC 7517 JWK Set keyed by `kid`, containing the tenant's current
signing key plus the previous key during a rotation grace window. An unknown
slug returns an empty key set, never a `404`. Responses are cached for 5
minutes, so re-fetch on an unknown `kid`. `client_secret_basic` is the default
and the safe minimum — confirm `private_key_jwt` with your operator before
relying on it.

## Errors

| You observe | Cause | Fix |
|---|---|---|
| `403 INSUFFICIENT_PERMISSIONS` | Key lacks the required scope (reason `mint_handoff_requires_scope` when `mintHandoff: true` without `portal-sso-mint`) | Have the operator regenerate the SSO key with the needed capability |
| `403 LICENSE.REQUIRED` | The tenant doesn't hold the CustomerPortal license (`details.licenseKey: "CustomerPortal"`) | Have your LedgerBee administrator activate the customer-portal module |
| `401 UNAUTHORIZED` | Invalid / missing key, or IP not allow-listed | Send a valid `x-api-key`; check allow-listing with the operator |
| `403` "handoff is not enabled" | Mint called while the tenant's back-channel toggle is off | Operator enables handoff on the SSO tab |
| `400` on mint | Unknown role, a `customerId` outside your tenant, no memberships, or several without exactly one `primary` | Fix the asserted membership set |
| Browser lands on login with `?ssoError=1` | Redeem of a missing / expired / used ref, or an OIDC start/callback failure | Mint a fresh ref; for OIDC, restart the login |
