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.
Where do
issuer,clientIdandredirectUricome from? Register an application in your environment console — see Integrate your app.
npm install @cboxdk/id-js// 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');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 },
});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.
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
}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.
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.
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.
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!,
});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.
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 `feature:action`, 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, ... }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.
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.
MIT © Cbox.