@cboxdk/id-js
v0.17.0
Published
Turnkey Cbox ID client for JavaScript/TypeScript — OpenID Connect login (PKCE + id_token verification via JWKS), hosted profile-management redirect, machine tokens, UserInfo, RFC 7662 introspection, and webhook signature verification. Runs on Node, edge,
Maintainers
Readme
@cboxdk/id-js
Turnkey Cbox ID client for JavaScript / TypeScript. It speaks standard OpenID Connect against a Cbox ID instance — so integrating is a redirect and a callback, not a rewrite — and adds the conveniences a hosted-identity product needs:
- Login — one redirect, one callback. PKCE (S256), a CSRF
state, a nonce, and fullid_tokenverification (signature against the instance's JWKS viajose, plus issuer, audience and nonce) are handled for you. - Hosted profile management — send a signed-in user to the instance's own account page (password, MFA, passkeys, sessions) and back to your app.
- Back-channel calls — machine (client-credentials) tokens, UserInfo, RFC 7662 introspection, RFC 7009 revocation.
- Webhook / action verification — confirm an inbound
X-Cbox-Signature.
Runs on Node, edge runtimes and the browser (built on Web Crypto and fetch), with a
first-class Next.js adapter.
Install
Where do
issuer,clientIdandredirectUricome from? Register an application in your environment console — see Integrate your app.
npm install @cboxdk/id-jsNext.js (App Router)
// lib/cbox.ts
import { createCboxId } from '@cboxdk/id-js/nextjs';
// Reads CBOX_ID_ISSUER / CBOX_ID_CLIENT_ID / CBOX_ID_CLIENT_SECRET / CBOX_ID_REDIRECT_URI
export const cboxId = createCboxId();// app/auth/sign-in/route.ts
import { cboxId } from '@/lib/cbox';
export const GET = () => cboxId.signIn();// app/auth/callback/route.ts
import { NextResponse, type NextRequest } from 'next/server';
import { cboxId } from '@/lib/cbox';
export async function GET(request: NextRequest) {
const user = await cboxId.callback(request); // verifies state, PKCE and the id_token
// create your own session for `user.id` (the stable subject), then:
return NextResponse.redirect(new URL('/dashboard', request.url));
}Send users to hosted profile management:
// app/account/route.ts
import { cboxId } from '@/lib/cbox';
export const GET = () => cboxId.profileRedirect('/dashboard');Any framework (the core)
CboxIdClient is framework-agnostic — it hands you the values to persist and takes
them back:
import { CboxIdClient } from '@cboxdk/id-js';
const client = new CboxIdClient({
issuer: 'https://id.acme.com',
clientId: process.env.CBOX_ID_CLIENT_ID!,
clientSecret: process.env.CBOX_ID_CLIENT_SECRET,
redirectUri: 'https://app.acme.com/auth/callback',
});
// Start login — persist state/codeVerifier/nonce (e.g. signed httpOnly cookies).
const { url, state, codeVerifier, nonce } = await client.createAuthorizationRequest();
// redirect the user to `url` ...
// On the callback:
const user = await client.authenticate({
params: { code, state: callbackState },
stored: { state, codeVerifier, nonce },
});Organizations
A sign-in can be bound to one organization. The tokens then carry org, org_name, the
person's membership tier in it (org_role), and the app roles / permissions they hold
there — so switching organization means a new authorization, not a flag on the old
session.
// Bind to an organization you already know (the person must be an active member):
await client.createAuthorizationRequest({ organization: 'org_2x…' });
// Always show the hosted organization picker, with your guess preselected:
await client.createAuthorizationRequest({
prompt: 'select_organization',
organizationHint: 'org_2x…',
});
// Hosted "create a team" step; the person becomes its owner and the sign-in
// continues bound to the new organization:
await client.createAuthorizationRequest({ prompt: 'create_organization' });| Option | Sent as | Meaning |
|---|---|---|
| organization | organization | Bind the sign-in to this organization. |
| organizationHint | organization_hint | Preselect it in the picker; the person may choose another. |
| prompt: 'select_organization' | prompt=select_organization | Always show the picker. |
| prompt: 'create_organization' | prompt=create_organization | Create an organization first. |
organization cannot be combined with either organization prompt — it has already made
the choice they ask the person to make — and the SDK refuses the combination rather than
sending it. Use organizationHint with the picker instead.
Switching
switchOrganization(id) is createAuthorizationRequest({ organization: id }) under a name
that says what it is for. Persist and redirect exactly as for a sign-in; Cbox ID already
has the person's session, so they normally come straight back without seeing a form.
// app/auth/switch-organization/route.ts (Next.js)
import { NextResponse, type NextRequest } from 'next/server';
import { cboxId } from '@/lib/cbox';
export async function GET(request: NextRequest) {
const org = request.nextUrl.searchParams.get('org');
if (!org) return NextResponse.redirect(new URL('/', request.url));
return cboxId.switchOrganization(org);
}The binding is checked, not trusted. The request echoes organization; persist it with
state, codeVerifier and nonce and pass it back as stored.organization, and
authenticate() refuses tokens for any other organization. (The Next.js adapter does this
in a cookie for you.) An instance that predates organization selection ignores the
parameter and answers for whichever organization the session already had — without the
check, your app would show the new organization's name over the old one's data.
Replace your session with the user the callback returns rather than patching the old one:
org_role, roles and permissions can all differ between organizations. A person who
is not (or no longer) an active member comes back with error=access_denied:
import { AuthenticationError } from '@cboxdk/id-js';
try {
user = await cboxId.callback(request);
} catch (e) {
if (e instanceof AuthenticationError && e.error === 'access_denied') {
// Not a member of that organization — send them back to the one they were in.
}
}Listing a person's organizations
Request the organizations scope and user.organizations lists every organization the
person is an active member of — { id, name, role } — for an organization switcher. It is
a separate scope because it discloses memberships across unrelated customers; a plain
profile sign-in does not get it.
Reading the claims
The signed-in user carries them typed:
user.organization; // { id, name, role } | null — role is 'owner' | 'admin' | 'developer' | 'member' | 'viewer' | null
user.roles; // string[]
user.permissions; // string[]
user.actor; // { sub, actor } | null — see support sessions below
user.sessionId; // the id_token's `sid` | null — keep it to match a back-channel logoutThe same helpers work on the user and on a claim set you verified yourself, such as an access token's payload on a resource server:
import { organization, hasPermission, hasRole, isSupportSession } from '@cboxdk/id-js';
if (!hasPermission(payload, 'invoices:create')) return forbidden();
if (organization(user)?.role === 'owner') showBilling();Matching is exact — invoices:* does not grant invoices:delete. An org_role this SDK
version does not recognise reads as null, never as a tier it would have to guess.
Support sessions
A staff member can act as one of your users for a limited time (at most an hour, no refresh
token, with a recorded reason). Those tokens carry the RFC 8693 act claim naming the
staff member, and isSupportSession() reports it:
if (isSupportSession(user)) {
// Show a banner, and refuse what a helper should never do on someone's behalf:
// changing their password, their email, their payout details.
}It is fail-closed: any act claim counts, including one whose shape the SDK cannot
read (user.actor.sub is then null). A claim it cannot parse is not evidence that nobody
else is at the keyboard.
From a CLI (device flow)
A command-line tool has no browser to redirect, and neither does a CI job, a container or a TV app. The device authorization grant (RFC 8628) is for all of them: your program prints a short code, the person approves it on whatever device is already in their hand, and your program collects the tokens.
Register the app as "CLI or device" in the console. It is issued no secret — a binary on somebody's laptop cannot keep one — and has no redirect URI, so neither appears here:
import { CboxIdClient } from '@cboxdk/id-js';
const cbox = new CboxIdClient({
issuer: process.env.CBOX_ID_ISSUER!,
clientId: process.env.CBOX_ID_CLIENT_ID!,
scopes: ['openid', 'profile', 'email', 'offline_access'],
});
const auth = await cbox.requestDeviceAuthorization();
console.log(`Open ${auth.verificationUri} and enter ${auth.userCode}`);
// Blocks until they approve. Honours the server's interval, backs off on `slow_down`,
// and stops on a decline or an expired code. Pass an AbortSignal so Ctrl-C works.
const user = await cbox.pollDeviceToken(auth);
console.log(`Signed in as ${user.email}`);
// Persist user.refreshToken (mode 0600) so the next run does not ask again.The scopes are bounded by what the app is registered for: a device request naming one
outside that ceiling is refused with invalid_scope rather than quietly reduced, because
no browser is in front of it to notice a smaller grant. Keep offline_access unless you
want the person re-approving every hour.
See Sign in from a CLI for the protocol itself and where to store the tokens.
In the browser (publishable keys)
Everything above needs a server: it holds your client secret. A publishable key is the opposite — it is public on purpose, it ships in your bundle, and it lets a page read its own sign-in configuration without routing that through your backend.
import { CboxIdFrontend } from '@cboxdk/id-js'
const frontend = new CboxIdFrontend({
issuer: 'https://id.acme.com',
publishableKey: 'pk_live_…', // safe in your bundle
})
const config = await frontend.config()
// → endpoints, social buttons, and the customer's theme
const { user } = await frontend.session(accessToken)
// → { id, email, name } or nullWhat makes a public key safe: every key carries an allow-list of origins, and a request
from anywhere else is refused. A key that leaks still only works from the sites you
registered — the same shape as a Stripe publishable key plus registered domains. Add your
origins when you create the key in the console; exact matches only, so https://acme.com
does not cover https://www.acme.com.
config() is fetched once per instance and shared between however many components ask for
it at the same time. session() returns { user: null } rather than throwing when nobody
is signed in — signed-out is a state, not an error, and a user button renders on pages
nobody has signed in on.
The publishable key grants nothing on its own: the access token is the entire authority for
session(). Pasting a client secret here throws immediately rather than failing later as an
opaque 401.
Failures are typed, because the four ways this goes wrong need four different responses and telling them apart from a message string breaks the moment somebody rewords it:
import { FrontendApiError } from '@cboxdk/id-js'
try {
await frontend.config()
} catch (e) {
if (e instanceof FrontendApiError) {
e.code // 'origin_not_allowed' | 'rate_limited' | 'unavailable' | 'malformed'
e.retryAfter // seconds, when the server said
}
}Transient failures are retried twice by default (retries). A refusal is not — it is a
configuration problem, and retrying it only delays you finding out. A rate limit is
surfaced rather than hammered.
Signing in from your own form
This half needs a server that implements it.
config()andsession()are served by thecboxdk/laravel-idpackage itself, so they work against any instance built on it.signIn(),submitSecondFactor()and the passkey calls are not: they post to/frontend/v1/sign-in*, which is the sign-in policy and therefore lives in the application rather than the package. Cbox ID implements them; a barelaravel-idinstall answers 404 to all four.In a browser a 404 on a cross-origin request is indistinguishable from a dead network, so this surfaces as
FrontendApiErrorwithcode: 'unavailable'— nothing mentions a missing route. Ifconfig()works andsignIn()says the service is unreachable, this is why.
const result = await frontend.signIn(email, password)
if (result.status === 'ok') {
// Spend the ticket on the ordinary authorize flow, with your own PKCE challenge.
window.location.href = `${config.endpoints.authorization}?${new URLSearchParams({
client_id, redirect_uri, response_type: 'code',
code_challenge, code_challenge_method: 'S256',
login_ticket: result.loginTicket,
})}`
}You get a ticket, never a token. Handing tokens to a page that proved a password is the
implicit grant, which OAuth 2.1 removes: tokens in a URL, in history, in Referer, with no
client authentication and no PKCE binding. The ticket is single-use, lasts sixty seconds,
and is for one redirect — not for storing.
The other outcomes are mfa_required, otp_required and sso_required. That last one
matters: showing "wrong password" to somebody whose organization mandates SSO sends them to
support instead of to their identity provider.
For the first two, finish with the code:
if (result.status === 'mfa_required' || result.status === 'otp_required') {
// The third argument is not optional for an emailed code: an `otp_required` finished
// with the default 'mfa' is answered against the wrong challenge and refused.
const method = result.status === 'otp_required' ? 'otp' : 'mfa'
const done = await frontend.submitSecondFactor(result.mfaToken, code, method)
// done.status === 'ok' → spend done.loginTicket exactly as above
}Passkeys
const options = await frontend.passkeyOptions()
const assertion = await navigator.credentials.get({
publicKey: { ...options, challenge: decode(options.challenge) },
})
const result = await frontend.signInWithPasskey(options.challenge_token, serialise(assertion))The challenge_token carries the challenge between the two requests WebAuthn needs, in
place of the session cookie a cross-origin page does not have. It is single-use.
The relying party is the issuer's, not your page's. WebAuthn binds an assertion to the
origin that asked for it — that is what makes a passkey phishing-resistant — so an embedded
button on acme.com still authenticates against the issuer's rpId. If your page is on a
different registrable domain from your Cbox ID issuer, passkeys need the hosted page or a
subdomain of the issuer. That is WebAuthn working as designed rather than a limitation to
route around, and it is the first thing that surprises people.
The mfaToken carries the pending state, because a cross-origin page has no session cookie
to carry it in. A TOTP code or a recovery code both work — an embedded form that could not
accept a recovery code would strand exactly the people that escape hatch exists for. A wrong
code costs an attempt, not the sign-in: five are allowed before the token dies.
Present every refusal identically. invalid covers a wrong password, an unknown address
and a locked account — the server refuses to distinguish them, because that is the
enumeration oracle, and a UI that distinguishes them rebuilds it.
Back-channel calls
const token = await client.machineToken({ scopes: ['reports.read'] }); // as your app
const claims = await client.userinfo(user.accessToken); // as a user
const introspection = await client.introspect(someToken); // RFC 7662
await client.revoke(user.refreshToken!, 'refresh_token'); // RFC 7009Revoking a refresh token drops the whole token family — that's what "sign out
everywhere" needs. machineToken, introspect and revoke authenticate as the
client, so they require a clientSecret; userinfo authenticates with the user's
own access token and does not.
API keys for your API
Your customers can create API keys for your API on Cbox ID's hosted page, and your API asks Cbox ID whether a key it was handed is good. Link people to the page:
client.apiKeysUrl({ returnTo: 'https://app.acme.com/settings' });
// → {issuer}/account/api-keys?client_id=<your client>&return_to=…
// options: clientId (another of your apps), returnTo, organization (which of theirs)Verify a key on your server — never in a browser, since it uses your client secret. It
lives in its own entry, @cboxdk/id-js/server, so a browser bundle never pulls it in:
import { ApiKeyVerifier } from '@cboxdk/id-js/server';
import { hasPermission } from '@cboxdk/id-js';
const keys = new ApiKeyVerifier({ issuer, clientId, clientSecret, cacheTtlMs: 10_000 });
const answer = await keys.verifyApiKey(request.headers.get('x-api-key') ?? '');
if (!answer.active) return new Response(null, { status: 401 });
if (!hasPermission(answer, 'invoices:create')) return new Response(null, { status: 403 });
// answer: { active, key_id, sub, org, org_role, permissions, client_id, expires_at }On Next.js the adapter does the same with its own configuration:
await cboxId.verifyApiKey(key) and cboxId.apiKeysUrl().
- Every bad key — unknown, revoked, expired, another app's, a holder who left the
organization — is
{ active: false }, with no reason. That is Cbox ID's design, so the endpoint cannot be used to probe keys. permissionsis already re-capped to what the holder holds for your app now; a demotion takes effect on the next verification.- An answer naming another app's
client_idis refused with anAuthenticationError, as is a failed call (wrong client credentials, instance unreachable). Treat a throw as "do not let this request in". cacheTtlMs(off by default, at most 60 seconds) caches active answers per key, never past the key'sexpires_at. While an answer is cached, a revoked key keeps working — keep it short. Refusals are never cached.
Migrating off an old login
Bulk-importing users with their existing hashes is the first answer, and the better one. When you cannot export those hashes, Cbox ID can ask your old system instead — and import each person at the moment they sign in.
Declare where it lives, alongside your roles:
export default defineAuthz({
roles: [...],
legacyLogin: {
url: 'https://acme.com/api/cbox-legacy',
secret: process.env.CBOX_LEGACY_SECRET!, // 32+ chars
},
})It rides the manifest because it is the same kind of fact as a role — something your app knows about itself, deployed with the code. It does not take effect on its own: unlike a role, this names a URL that every unknown email and the password typed with it will be offered to, so an operator approves it once in the console. Changing the URL later drops that approval, deliberately.
Then write the handler — one function, no signature code:
// app/api/cbox-legacy/route.ts
import { createLegacyVerifier } from '@cboxdk/id-js'
export const POST = createLegacyVerifier({
secret: process.env.CBOX_LEGACY_SECRET!,
async verify(email, password) {
const row = await db.users.findByEmail(email)
if (!row || !(await argon2.verify(row.password, password))) return null
return { email: row.email, name: row.name, emailVerified: !!row.confirmedAt }
},
})The factory owns the HMAC check, the freshness window, the constant-time compare and the response shape — the parts that are easy to get subtly wrong. You own the one function that knows your database.
Return null for "wrong password". Throwing is different: it means your store could
not decide, and is answered with a 503 so Cbox ID refuses the sign-in rather than reading
an outage as a bad password. Returning passwordHash lets the person keep their password
verbatim; omit it and Cbox ID hashes the one they just proved they know.
It returns a Request → Response handler, so it drops into Next.js route handlers, Remix,
Hono, Bun and Deno unchanged.
Verify webhooks
import { verifyWebhook } from '@cboxdk/id-js';
const ok = await verifyWebhook({
payload: rawBody, // the exact bytes received
signatureHeader: req.headers['x-cbox-signature'],
secret: process.env.CBOX_ID_WEBHOOK_SECRET!,
});Token Vault
Broker downstream credentials (API keys for OpenAI, GitHub, …) through the instance's
Token Vault: provision + grant with a vault.manage token, and let an authorized
agent client redeem the plaintext with a vault.lease token.
// Provisioning backend (vault.manage)
const admin = client.vault(await client.machineToken({ scopes: ['vault.manage'] }));
const secret = await admin.store({ name: 'openai', provider: 'openai', secret: 'sk-live-…' });
await admin.grant(secret.id, 'agent-1');
// Agent worker (vault.lease) — keyed on its own client
const agent = client.vault(await client.machineToken({ scopes: ['vault.lease'] }));
const lease = await agent.lease(secret.id, 'call openai');
// use lease.secret immediately; it is never persistedA lease with no live grant is refused — the vault is deny-by-default.
Roles & permissions (federated RBAC)
Declare your app's authorization roles and permissions in code, then push them
to Cbox ID on deploy. Your app owns what a role means; Cbox ID owns identity and who
holds each role — assignments arrive back in the token's roles / permissions
claims for you to enforce. Requires the app's client to hold the apps.manifest scope.
import { defineAuthz, publishManifest } from '@cboxdk/id-js';
// Declare the catalog (validated: keys are lowercase `feature:action` slugs, and roles
// must reference declared permissions). Keep this next to the code that enforces it.
export const authz = defineAuthz({
permissions: [
{ key: 'invoices:create', description: 'Create invoices' },
{ key: 'invoices:read', description: 'View invoices' },
],
roles: [
{ key: 'billing-admin', name: 'Billing Admin', description: 'Full billing access',
permissions: ['invoices:create', 'invoices:read'] },
],
});
// Push it — run from a deploy step or a `package.json` script. Idempotent: an
// unchanged catalog is a server-side no-op (the manifest carries a content hash).
const summary = await publishManifest(
{
issuer: process.env.CBOX_ID_ISSUER!,
clientId: process.env.CBOX_ID_CLIENT_ID!,
clientSecret: process.env.CBOX_ID_CLIENT_SECRET!,
},
authz,
);
// → { unchanged, roles_declared, permissions_declared, ... }Staff roles and self-serve permissions
Two flags decide who may hand something out:
tenantAssignable: falseon a role makes it a staff role — for your own support or operations people, held environment-wide across every customer. Cbox ID never lists or accepts it on an organization's own admin pages; only an environment administrator can grant it. Roles default totrue.tenantAssignable: trueon a permission lets an organization's administrators put it in custom roles they build themselves. Permissions default tofalse: internal unless you opt in.
export const authz = defineAuthz({
permissions: [
{ key: 'parcels:read', description: 'View parcels', tenantAssignable: true },
// Lets a staff member start a support session in this app (see "Support sessions").
{ key: 'support:impersonate', description: 'Act as a customer' },
],
roles: [
{ key: 'viewer', name: 'Viewer', permissions: ['parcels:read'] },
{ key: 'support', name: 'Support', description: 'Our support team',
permissions: ['support:impersonate', 'parcels:read'], tenantAssignable: false },
],
});On the wire the flags are tenant_assignable, and only their non-default state is sent
(false on a role, true on a permission). Both are part of the manifest's version, so
marking a role staff-only in a new deploy re-syncs it rather than being skipped as
unchanged. A value that is not a real boolean — the string "false" from a YAML file, say
— is refused, because it would otherwise read as the opposite of what it says.
publishManifest mints a client-credentials token (scope=apps.manifest) and POSTs
the manifest to {issuer}/api/v1/apps/manifest. It is a server-side operation — keep
your clientSecret off the browser. The wire format matches the PHP SDK
(cboxdk/laravel-id-client), so any SDK can publish the same catalog.
Security & scope
Login is hardened by default — PKCE, state, nonce, and full id_token verification
(signature/issuer/audience) via jose; webhook checks are constant-time within a
freshness window. Keep clientSecret and webhook secrets server-side.
This is a client. It authenticates users and calls a Cbox ID instance's standard
endpoints; it does not configure SSO, run SCIM, or manage organizations — those are
platform capabilities of cboxdk/laravel-id.
Report vulnerabilities via this repo's GitHub Private Vulnerability Reporting.
License
MIT © Cbox.
