01.software Docs

Customers

Complete hosted sign-in, current-customer readback and logout in your app.

Customers

Prepare

Enable customers and obtain a Publishable Key for the intended workspace/environment. Ask its operator to register an enabled hosted clientId, exact normalized HTTPS app origin and callback URI (APP_ORIGIN/api/account/callback). Obtain the platform-host issuer for that environment; it is not a per-client registration value. Register the logout URI (APP_ORIGIN/account) because this recipe sends postLogoutRedirectUri; that registration is needed only when sending it. Wildcards and loopback hosts are not accepted. Browser API origins are a separate registration. Use an HTTPS deployment or HTTPS development origin. Copy this into a Next.js App Router app with the @/* alias rooted at the project directory and install server-only (npm install server-only@0.0.1).

These examples use the published SDK 0.65.1, Next.js 16.3.8 App Router, React 19.2.4, and Node 24.15.0. Install the SDK in your own app with npm install @01.software/sdk@0.65.1. The examples use force-dynamic; SDK calls run on requests, not at build time. This assumes no cacheComponents or static export. Compile and controlled local transport checks do not certify hosted availability or remote workspace admission.

# .env.local (configure the same values in the deployed app)
NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY=pk01_...
SOFTWARE_API_URL=https://your-console-origin.example
CUSTOMER_OAUTH_CLIENT_ID=your-registered-client-id
CUSTOMER_OAUTH_ISSUER=https://your-hosted-issuer.example
APP_ORIGIN=https://your-app.example

The issuer and API URL must belong to the environment your operator confirmed. No operator PAT, Secret Key, signing secret, or internal exchange secret belongs in this customer application.

Implement hosted sign-in

Copy all three files. Each request creates its own non-persisted SDK client. The callback consumes state/verifier cookies, checks state, exchanges the PKCE-bound code once, and reads identity through me(). The access/refresh pair stays in Secure HttpOnly host cookies, never browser storage or URLs. The SDK does not verify idToken; treat it as opaque.

// lib/customer.ts
import 'server-only'
import { createClient } from '@01.software/sdk'
import { cookies } from 'next/headers'

export const accessCookie = '__Host-customer-access'
export const refreshCookie = '__Host-customer-refresh'
export const cookieOptions = {
  httpOnly: true,
  secure: true,
  sameSite: 'lax' as const,
  path: '/',
}

export function customerClient(token?: string) {
  return createClient({
    publishableKey: process.env.NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY!,
    apiUrl: process.env.SOFTWARE_API_URL,
    customer: {
      persist: false,
      token,
      oauth: {
        clientId: process.env.CUSTOMER_OAUTH_CLIENT_ID!,
        issuer: process.env.CUSTOMER_OAUTH_ISSUER!,
      },
    },
  })
}

export async function currentCustomer() {
  const jar = await cookies()
  const client = customerClient(jar.get(accessCookie)?.value)
  const customer = await client.customer.auth.me()
  return { client, customer, hadToken: Boolean(jar.get(accessCookie)?.value) }
}
// app/api/account/[action]/route.ts
import { cookies } from 'next/headers'
import {
  accessCookie,
  refreshCookie,
  cookieOptions,
  customerClient,
} from '@/lib/customer'

export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
type Context = { params: Promise<{ action: string }> }
const stateCookie = '__Host-customer-state'
const verifierCookie = '__Host-customer-verifier'

function appOrigin() {
  const url = new URL(process.env.APP_ORIGIN!)
  if (url.protocol !== 'https:' || url.origin !== process.env.APP_ORIGIN) {
    throw new Error('APP_ORIGIN must be an exact HTTPS origin')
  }
  return url.origin
}

async function handle(request: Request, context: Context) {
  const { action } = await context.params
  const jar = await cookies()
  const origin = appOrigin()
  const reply = (body: object, status = 200) =>
    Response.json(body, { status, headers: { 'Cache-Control': 'no-store' } })
  const clearSession = () => {
    for (const name of [accessCookie, refreshCookie]) {
      jar.set(name, '', { ...cookieOptions, maxAge: 0 })
    }
  }
  const clearFlow = () => {
    for (const name of [stateCookie, verifierCookie]) {
      jar.set(name, '', { ...cookieOptions, maxAge: 0 })
    }
  }
  const redirect = (path: string) =>
    new Response(null, {
      status: 303,
      headers: { Location: origin + path, 'Cache-Control': 'no-store' },
    })
  const client = customerClient(jar.get(accessCookie)?.value)
  const oauth = client.customer.oauth!
  const redirectUri = origin + '/api/account/callback'
  if (request.method === 'POST' && request.headers.get('origin') !== origin) {
    return reply({ error: 'origin_not_allowed' }, 403)
  }
  try {
    if (request.method === 'GET' && action === 'login') {
      const flow = await oauth.createAuthorizationUrl({ redirectUri })
      jar.set(stateCookie, flow.state, { ...cookieOptions, maxAge: 600 })
      jar.set(verifierCookie, flow.codeVerifier, {
        ...cookieOptions,
        maxAge: 600,
      })
      return new Response(null, {
        status: 303,
        headers: {
          Location: flow.authorizationUrl,
          'Cache-Control': 'no-store',
        },
      })
    }
    if (request.method === 'GET' && action === 'callback') {
      const expectedState = jar.get(stateCookie)?.value
      const codeVerifier = jar.get(verifierCookie)?.value
      clearFlow()
      if (!expectedState || !codeVerifier)
        return reply({ error: 'flow_expired' }, 400)
      const { code } = oauth.parseCallback(request.url, { expectedState })
      const tokens = await oauth.exchangeCode({
        code,
        codeVerifier,
        redirectUri,
      })
      // Read identity from me(); the SDK does not verify idToken.
      const customer = await customerClient(
        tokens.accessToken,
      ).customer.auth.me()
      if (!customer) {
        clearSession()
        return reply({ error: 'session_invalid' }, 401)
      }
      jar.set(accessCookie, tokens.accessToken, {
        ...cookieOptions,
        maxAge: tokens.expiresIn,
      })
      jar.set(refreshCookie, tokens.refreshToken, {
        ...cookieOptions,
        maxAge: 60 * 60 * 24 * 30,
      })
      return redirect('/account')
    }
    if (request.method === 'POST' && action === 'logout') {
      const refreshToken = jar.get(refreshCookie)?.value
      clearSession()
      clearFlow()
      if (!refreshToken) return reply({ error: 'revocation_unconfirmed' }, 409)
      await oauth.logout({
        refreshToken,
        postLogoutRedirectUri: origin + '/account',
      })
      return redirect('/account')
    }
    if (request.method === 'POST' && action === 'refresh') {
      const refreshToken = jar.get(refreshCookie)?.value
      if (!refreshToken) return reply({ error: 'sign_in_required' }, 401)
      const tokens = await oauth.refresh({ refreshToken })
      const customer = await customerClient(
        tokens.accessToken,
      ).customer.auth.me()
      if (!customer) {
        clearSession()
        return reply({ error: 'session_invalid' }, 401)
      }
      jar.set(accessCookie, tokens.accessToken, {
        ...cookieOptions,
        maxAge: tokens.expiresIn,
      })
      jar.set(refreshCookie, tokens.refreshToken, {
        ...cookieOptions,
        maxAge: 60 * 60 * 24 * 30,
      })
      return redirect('/account')
    }
    return reply({ error: 'not_found' }, 404)
  } catch {
    // Do not return tokens, callback query strings, or raw upstream errors.
    if (action === 'callback' || action === 'refresh') clearSession()
    return reply(
      {
        error:
          action === 'logout'
            ? 'revocation_unconfirmed'
            : 'authentication_failed',
      },
      502,
    )
  }
}

export const GET = handle
export const POST = handle
// app/account/page.tsx
import { cookies } from 'next/headers'
import { currentCustomer, refreshCookie } from '@/lib/customer'

export const dynamic = 'force-dynamic'

export default async function AccountPage() {
  const { customer } = await currentCustomer()
  const canRefresh = Boolean((await cookies()).get(refreshCookie)?.value)
  if (!customer && !canRefresh) return <a href="/api/account/login">Sign in</a>
  return (
    <main>
      <h1>
        {customer ? `Welcome, ${customer.name}` : 'Session needs refresh'}
      </h1>
      <form method="post" action="/api/account/refresh">
        <button>Refresh session</button>
      </form>
      <form method="post" action="/api/account/logout">
        <button>Sign out</button>
      </form>
    </main>
  )
}

Normal, empty, and failure results

Open /account, sign in at the registered issuer, and return to /account: the displayed name comes from me(). With no access token or a 401 from me(), the page shows Sign in unless a refresh cookie remains. A remaining refresh cookie keeps Refresh session and Sign out available, including after the 900-second access cookie expires; feature/workspace/network errors propagate to the app error boundary instead of being presented as an anonymous success. Add your normal Next.js error.tsx retry UI without exposing upstream error bodies.

Use POST Refresh session to rotate the client-bound refresh token; refresh remains possible after access expiry while the refresh token is valid. A successful refresh redirects back to /account. Serialize refresh requests per session; never replay an old token or retry token exchange automatically. This minimal cookie example supports one active login flow and one refresh at a time per browser. Starting a second login replaces its flow cookies. For concurrent multi-tab rotation, use a shared server session with serialization rather than independent cookie copies.

Sign out clears app cookies and requests hosted family revocation. SDK success means hosted accepted the logout request; whether it swept sessions is not exposed to the app. Hosted sweeps when it recognizes the submitted refresh token as an unexpired token issued to this client in this workspace, even if the token was already rotated or its family was already revoked. Missing, expired, or differently client-bound tokens do not trigger a sweep. The app cannot distinguish these outcomes. As specified by the hosted logout contract, the sweep covers that customer’s sessions across all clients in the workspace, including legacy-direct sessions, using its session fence. A new family issued at/after that fence survives internal retries of the same durable revocation intent; this is not a guarantee about replaying an app logout request. Cookie removal alone is not revocation. If the request fails or the refresh cookie is missing, the response says revocation_unconfirmed; the app is locally signed out, and server revocation remains unconfirmed. Do not show a successful server-revocation claim or retry with guessed credentials. Sign in again or use your account support flow.

Check missing/expired state cookies, mismatched state, callback replay, bad PKCE verifier, disabled/wrong client, unregistered origin/redirect/logout URI, expired/reused refresh, and cross-workspace credentials. Token, identity, and app session responses must be no-store. This recipe's same-origin POST check protects its cookie-backed refresh/logout routes; Next.js Server Actions handle their own origin checks. Keep callback query strings, tokens, PII, and raw errors out of access logs, analytics, screenshots and client responses.

Existing apps: direct legacy compatibility

Only keep this path for an existing app that already uses direct auth. Do not use it to work around a missing hosted client. Supply test inputs privately; the example returns booleans rather than customer data.

// Existing direct-auth applications only; call from a trusted server.
import { createClient } from '@01.software/sdk'

export async function legacySession(email: string, password: string) {
  const client = createClient({
    publishableKey: process.env.NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY!,
    apiUrl: process.env.SOFTWARE_API_URL,
    customer: { persist: false },
  })
  await client.customer.auth.login({ email, password })
  const errors: unknown[] = []
  let signedIn = false
  try {
    signedIn = (await client.customer.auth.me()) !== null
  } catch (error) {
    errors.push(error)
  }
  try {
    await client.customer.auth.logoutAsync()
  } catch (error) {
    errors.push(error)
  }
  try {
    client.customer.auth.logout()
  } catch (error) {
    errors.push(error)
  }
  if (errors.length === 1) throw errors[0]
  if (errors.length > 1) {
    throw new AggregateError(errors, 'Customer session and cleanup failed', {
      cause: errors[0],
    })
  }
  return { signedIn, revocationConfirmed: true }
}

After login succeeds, identity lookup and both cleanup operations are attempted in order. A single error is rethrown unchanged. If cleanup also fails, AggregateError.cause and errors[0] preserve the original identity error; later entries preserve cleanup failures. Inspect these errors only on your trusted server, without returning raw errors or credentials to the browser.

logoutAsync() revokes only the Publishable Key workspace’s legacy-direct refresh family, including its rotated successor. It does not immediately revoke already issued access JWTs. It clears the local pair even on server failure and reports that failure. Synchronous logout() is local-only. Community Forum starter currently deletes only its app HttpOnly cookies on logout; it does not call SDK logout or revoke the server family. Hosted and direct logout have different effects.

Process a benefit voucher in Console

Select the Workspace explicitly, open the Customer detail, and find Benefit vouchers. The panel shows up to 10 vouchers, with available vouchers first.

  • tenant-admin and tenant-editor: choose Redeem for an available voucher.
  • tenant-admin: choose Void, select a reason, and confirm.
  • tenant-viewer: view only. A super-admin must also select a Workspace explicitly.

Check the Customer and voucher before confirming: redemption and void are irreversible. A correction requires a newly issued voucher. If a response is lost, retry in the same confirmation to recover the recorded result without applying the command twice. Role or Workspace changes can deny the command even while a button is visible; refresh the page after a denial.

Redeem requires both Ecommerce and Customers. Disabling Customers hides Customer detail and this panel; server reads and Customer self-reads remain available, as does server void. Issuance and staff apps use the server voucher channel.

Next Actions

Use this hosted session in Community and Events participation. Review Authentication & Keys and Deploy for origin, credential, and deployment checks.

On this page