Authentication
Every credential travels inside the sealed envelope, so no proxy in the path sees it. A client holds one credential at a time: a session or an API key.
Passkeys
Section titled “Passkeys”import { browserPasskey } from '@calimero-network/mero-js';
const passkey = browserPasskey(); // Touch ID, Windows Hello, a security key, a synced passkeyawait client.signUp(passkey, 'alice'); // new account and passkey: asks the passkey twiceawait client.signIn(passkey); // existing passkeyA sign-up prompts twice: once to register the new passkey, once for that
passkey to sign the account it creates. A registration carries no signature
by the new key, so the cluster takes the account only with that second
assertion (the node’s 412 CONFIRM_REQUIRED, which signUp answers).
browserPasskey() wraps navigator.credentials. Anything that implements
PasskeyProvider (create(options) and get(challenge, rpId, allowCredentials?),
answering base64url fields) works in its place, for example a software
authenticator in tests.
Each passkey is a device of the account. Adding or removing one is approved with a passkey the account already has (the new one cannot approve itself):
const credentialId = await client.addPasskey(newDevicePasskey, passkey); // another device, approved by `passkey`await client.labelPasskey(credentialId, 'work laptop');await client.removePasskey(credentialId, passkey); // never the last onePasskeys are per cluster
Section titled “Passkeys are per cluster”An account and its passkeys belong to one cluster. Clusters may share a
WebAuthn relying-party id (every Calimero Cloud cluster uses
calimero.network), so the browser also offers a passkey made on one cluster
when a page signs in on another. That sign-in fails with
PasskeyUnknownHere (401 PASSKEY_UNKNOWN_HERE): “this passkey belongs to
another Calimero cluster or was removed from this one; sign up here, or open
the app from the link for the cluster your account is on”. A removed passkey
gets the same answer, and the node never says which cluster or account a
passkey is from: it knows only its own.
To tell them apart, the node names each passkey after its cluster: a password
manager lists alice · eu-shared-1, or alice · kv-store (eu-shared-1) for one
made by signing up on an app’s frontend.
import { PasskeyUnknownHere } from '@calimero-network/mero-js';
try { await client.signIn(passkey);} catch (e) { if (e instanceof PasskeyUnknownHere) showSignUpOrRightClusterLink(); else throw e;}The client does not refresh for this error (it is not LoginRequired), and
trying the same passkey again fails the same way.
Sign in from your own site
Section titled “Sign in from your own site”A package’s frontend served by the cluster signs people in on the cluster’s
origin. To host it yourself (https://chat.example.com), a manager of the
package registers that origin, approved with their passkey:
await client.packageOrigins.add('com.example.chat', 'https://chat.example.com', passkey);// { package, origin, rpId: 'chat.example.com', addedBy, addedAt }; rpId defaults to the hostawait client.packageOrigins.list('com.example.chat');await client.packageOrigins.remove('com.example.chat', 'https://chat.example.com', passkey);To have the cluster serve the package’s frontend on that name too (a custom
domain, from the signed bundle), add it with served: true:
await client.packageOrigins.add('com.example.chat', 'https://chat.example.com', passkey, { served: true });The record is the cluster’s half: browsers reach the name once Calimero Cloud holds a certificate and a route for it.
On that site, signUp and signIn work as anywhere else: in a browser whose
window.location.origin is not one of the client’s nodes, they send it, and
the passkey is made for (and asserted with) the relying-party id the node
answers for it. Pass { origin } to name it yourself, or { origin: null } to
send none. The session is bound to the package, as on an app’s own frontend;
me() answers its app and origin, and its passkey confirmations are signed
for the same relying-party id.
try { await client.signIn(browserPasskey());} catch (e) { if (e instanceof OriginNotRegistered) showError(e.message); // "this site (https://…) is not registered for …" else throw e;}Removing the origin ends the sessions opened there: their next request is
refused 401 (LoginRequired).
Sessions
Section titled “Sessions”signUp and signIn answer a CalimeroSession
({ accountId, sessionToken, expiresAt, refreshToken }) and use it for every
request after. Keep it to resume without a passkey prompt:
const session = await client.signIn(passkey);sessionStorage.setItem('calimero', JSON.stringify(session));
// another page loadconst resumed = new CalimeroClient({ baseUrl, policy });resumed.useSession(JSON.parse(sessionStorage.getItem('calimero')!));await resumed.refresh(); // swap the refresh token for a new sessionA refresh token is used once: a reused one revokes the session. A session is
signed by the node that issued it, so it can stop verifying when that node is
replaced or cannot be reached; the client then refreshes by itself, once, on
any node, and repeats the request
(see Restarts and other nodes).
A session that refreshing cannot renew — expired, revoked, or a refresh token
already used — makes requests fail with LoginRequired.
Keep client.session after requests, not only after signIn: an automatic
refresh replaces it, and the refresh token you stored before is then spent.
await client.signOut(); // this sessionawait client.signOutEverywhere(); // every session of the account, on every deviceAPI keys
Section titled “API keys”Agents and servers authenticate with an API key (cal_...), scoped and
revocable. Creating one is approved with the account’s passkey, from a passkey
session (an API key cannot mint another). The key is returned once.
const { keyId, key } = await client.createApiKey( { label: 'ci', apps: [appId], // only these apps methods: ['get'], // only these methods readOnly: true, ttlSecs: 86_400, }, passkey,);
const agent = new CalimeroClient({ baseUrl, policy });agent.useApiKey(key);
await client.listApiKeys(); // never the keys themselvesawait client.revokeApiKey(keyId, passkey); // approved with the passkeyUpgrade keys for CI
Section titled “Upgrade keys for CI”An upgrade with a session needs a passkey every time, which a CI job does
not have. An upgrade key is made once, with the passkey, for one app: it
upgrades that app to signed releases of its package (by the package’s pinned
signer) with no passkey, and the node refuses it everything else,
reads included (403 UPGRADE_KEY_ONLY). The approval gates still apply:
who may upgrade the app, release deadlines and deployer grants.
// Once, by the app's owner:const { key } = await client.createApiKey( { label: 'ci', upgrades: { app: appId }, ttlSecs: 90 * 86_400 }, passkey,);
// In CI, after publishing 1.4.0 to the registry:const ci = new CalimeroClient({ baseUrl, policy });ci.useApiKey(key);await ci.upgradeApp(appId, { version: '1.4.0' });Revoke it with revokeApiKey; the app’s lastUpgrade names the key that
made each upgrade.
Confirmations
Section titled “Confirmations”Some requests need the account holder’s passkey over that exact request: a
method the app declares confirm, deleting an organization, upgrades,
transfers and approvals, and every grant of access: adding or removing a
passkey, creating an API key, setting or removing an organization or team
member, capabilities and invites. The node answers 412 CONFIRM_REQUIRED with a
challenge; the *Confirmed methods, and every method that takes a passkey,
sign it and send the request again.
await client.callConfirmed(app, 'clear', {}, passkey);await client.postConfirmed(`orgs/${org}/outbound`, { package: 'com.example.kv' }, passkey);A prompt the user dismisses rejects with ConfirmCancelled, and nothing more is
sent. An API key can answer a confirmation only if it was created with
allowConfirm.
Account recovery
Section titled “Account recovery”Someone who loses every passkey cannot sign in, and nobody can act for them. A recovery code, made with a passkey and shown once, is the way back (core #4844):
const code = await client.createRecoveryCode(passkey); // show it once; keep it offline
// Every passkey lost, signed out: the code and a new passkey start a recovery.const { accountId, readyAt } = await fresh.startAccountRecovery(code, browserPasskey());// 72 hours later (until then, any passkey the account still has may cancel it):await fresh.finishAccountRecovery(browserPasskey(), accountId); // signed in; other sessions endA signed-in client sees a recovery under way in me().recoveryPending and
cancels it with cancelAccountRecovery(passkey). A code starts one recovery
and is spent whether it finishes or is cancelled; a wrong or spent one is
WrongRecoveryCode, finishing early RecoveryNotReady (with readyAt).