AsseteraAssetera Docs
Integrate

Tied-agent walkthrough (Next.js + BFF)

A hands-on walkthrough of building a Next.js app as a tied-agent tenant under Assetera. An OIDC login, a server-side session that holds the tokens, and a tenant-gated proxy to the Marketplace API.

Build a Next.js app that embeds Assetera's catalog for your customers, as a tied-agent tenant. Your app ships a thin Backend-for-Frontend (BFF): a server layer, under your control, that holds all tokens and calls Assetera on the browser's behalf.

One rule above all: the browser never receives a general Assetera API access or refresh token. Those tokens live server-side; the browser holds only an opaque, HttpOnly session cookie. Purpose-restricted, short-lived SDK and wallet tokens are the explicit exceptions described below.

Under a tied-agent arrangement, Assetera runs KYC/AML and you operate under its licence. See Tenancy & responsibility for reliance vs tied agent, and Authentication for the OIDC grants and token contract (this page links to it rather than re-explaining it).

The complete tied-agent flow

Before you integrate, your organization signs up with Assetera. Assetera provisions a tenant-scoped OIDC client, MetaKYC SDK credentials, allowed redirect origins, and the wallet configuration enabled for your environment. These are organization credentials, not end-user credentials.

The Keycloak sub is the join key. Do not create a separate random KYC user ID or wallet user ID. Use the signed-in customer's sub as MetaKYC's externalRefId; the embedded wallet receives the same subject through an audience-restricted token exchange.

Never accept a user ID for KYC from an untrusted browser body. Read sub from the validated server-side session. This prevents one customer from opening or resuming another customer's onboarding workflow.

Runtime shape

For ordinary Marketplace API calls there are three parties: the browser, your BFF, and Assetera Identity plus the Marketplace API. The browser talks to your BFF and does not receive the Marketplace API token. The onboarding SDK and embedded wallet use their own restricted browser-token boundaries, as shown in the complete flow above.

The example project

A minimal App Router layout: a page, three auth route handlers, one catch-all proxy, and two lib modules.

page.tsx

Snippets below are correct in shape, not a complete app. They use the standard openid-client and iron-session libraries; swap in your own equivalents. Environment hosts are never hardcoded; read them from env (ASSETERA_ISSUER, etc.).

Walkthrough

Register your OIDC client

During onboarding, Assetera provisions you a confidential OIDC client bound to your tenant. You register your redirect URI (/api/auth/callback) and receive a client ID plus a credential: a secret, or better, a key pair for private_key_jwt (Assetera stores only your public key, so no shared secret crosses the boundary). See Partner onboarding.

Your BFF discovers the issuer's endpoints from OIDC discovery:

// lib/oidc.ts
import * as client from "openid-client";

// e.g. https://auth.<base_domain>/realms/assetera
const issuerUrl = new URL(process.env.ASSETERA_ISSUER!);

export async function getConfig() {
  return client.discovery(issuerUrl, process.env.ASSETERA_CLIENT_ID!, {
    client_secret: process.env.ASSETERA_CLIENT_SECRET!,
  });
}

Login: redirect to Assetera (Auth Code + PKCE)

The login route generates a PKCE verifier, stashes it in the session, and redirects the browser to Assetera's hosted login. Assetera renders the sign-in methods (password, 2FA, passkey, SSO); you render nothing.

Callback: exchange the code server-side, store tokens in the session

Assetera redirects back with a short-lived code. Your BFF exchanges it server-to-server for an access token and a refresh token, stores them in the server-side session, and sets an opaque HttpOnly cookie. That cookie is all the browser ever gets. The tenant claim rides inside the token, not the cookie.

Proxy: attach the bearer token and call the Marketplace API

Every data call from the browser hits your BFF (cookie only). The proxy looks up the session, attaches the access token, and forwards to the Marketplace API. The API validates the token locally and scopes the response to your tenant claim, so your customer sees only your catalog. Refresh happens here too, fully server-side.

Gate on KYC before trading

Before showing trade controls, read the user's status from your same-origin BFF route backed by the Compliance API. The SDK runs the onboarding workflow; it is not the source you should use to authorize a trade. The UI gate is UX only, and the API re-enforces eligibility.

const response = await fetch("/api/kyc/status", { cache: "no-store" });
const status = await response.json();
if (!status.verified) {
  // redirect to KYC, or hide trade controls
}

Trade

With a verified user and a valid session, load the tenant-scoped market and fee data through the BFF. The execution route then depends on the market: an on-chain trade is prepared and signed through the user's wallet, while a configured bank-transfer primary sale uses the offchain-purchases API journey. Do not invent a generic order-write endpoint. Use the live Marketplace API contract and the off-chain sale guide for the operation enabled for your tenant.

Route handlers

// app/api/auth/login/route.ts
import * as client from "openid-client";
import { getConfig } from "@/lib/oidc";
import { getSession } from "@/lib/session";

export async function GET() {
  const config = await getConfig();
  const verifier = client.randomPKCECodeVerifier();
  const challenge = await client.calculatePKCECodeChallenge(verifier);

  const session = await getSession();
  session.pkceVerifier = verifier;
  await session.save();

  const url = client.buildAuthorizationUrl(config, {
    redirect_uri: process.env.ASSETERA_REDIRECT_URI!,
    scope: "openid profile marketplace",
    code_challenge: challenge,
    code_challenge_method: "S256",
  });

  return Response.redirect(url.href);
}
// app/api/auth/callback/route.ts
import * as client from "openid-client";
import { getConfig } from "@/lib/oidc";
import { getSession } from "@/lib/session";

export async function GET(req: Request) {
  const config = await getConfig();
  const session = await getSession();

  const tokens = await client.authorizationCodeGrant(config, new URL(req.url), {
    pkceCodeVerifier: session.pkceVerifier!,
  });

  // Tokens live server-side only. Browser gets nothing but the cookie.
  session.accessToken = tokens.access_token;
  session.refreshToken = tokens.refresh_token;
  session.pkceVerifier = undefined;
  await session.save();

  return Response.redirect(new URL("/", req.url).href);
}
// app/api/[...proxy]/route.ts
import { getSession } from "@/lib/session";

const API_BASE = process.env.ASSETERA_API_BASE!; // no hardcoded host

async function handle(req: Request, { params }: { params: { proxy: string[] } }) {
  const session = await getSession();
  if (!session.accessToken) return new Response("unauthenticated", { status: 401 });

  const upstream = `${API_BASE}/${params.proxy.join("/")}`;
  return fetch(upstream, {
    method: req.method,
    headers: {
      authorization: `Bearer ${session.accessToken}`, // attached server-side
      "content-type": req.headers.get("content-type") ?? "application/json",
    },
    body: req.method === "GET" ? undefined : await req.text(),
  });
}

export { handle as GET, handle as POST };

The catch-all proxy shown here forwards a broad path space for brevity. In production, allowlist the routes you expose and add refresh-on-401 so an expired access token is renewed server-side before retry.

Rules that keep it safe

  • No general API token in the browser. No Marketplace or Compliance API access/refresh token in localStorage, query strings, or non-HttpOnly cookies. The app cookie is opaque and HttpOnly, Secure, SameSite. The MetaKYC and wallet tokens are short-lived, audience-restricted exceptions and must not be reused as API bearer tokens.
  • Authorization is the API's job. Your UI may hide or show controls for UX, but the Marketplace API and Assetera Identity enforce the real rules, re-checked on every call. Never move authorization into the browser.
  • Tenant comes from the token, not the request. You cannot select or spoof a tenant on the wire; you get the tenant your client was issued for. (See Tenancy.)

Backend-only integration (no user browser)

If you have no interactive frontend (a server syncing catalog or acting on behalf of your book), skip the BFF and use the Client Credentials grant: your backend authenticates as its own service client, receives a token carrying your tenant claim, and calls the API directly. No user session, no cookie. See Authentication.

Assetera maintains a working first-party BFF implementation using this pattern. A partner starter is supplied during technical onboarding with the endpoints and environment values enabled for that tenant.

On this page