Skip to content

Authentication

On a phone, people sign in with their Calimero account. There is no node URL, username or password: the person approves this device in the Calimero wallet, and the app then talks to the hosted relay that serves their account. That is the only sign-in the Compose UI kit offers.

Code that talks to a node you run yourself — a server, a tool, a test — can still log in to it with credentials or hosted SSO; see Self-hosted nodes below.

The flow mirrors mero-react’s Cloud tab, without a browser page of its own:

  1. The app makes (once) an Ed25519 signing key and an X25519 key-delivery key for this device and keeps them in EncryptedSharedPreferences. The secrets never leave the phone.
  2. It opens https://wallet.cloud.calimero.network/account-enroll?enrol-device=…&enrol-kem=…&callback-url=…&state=… in a Chrome Custom Tab. The person approves with the passkey that holds their account.
  3. The wallet redirects to the callback with #credential=…&account=…&device=…&state=… — a certificate over this device’s keys, signed by the account root. The SDK verifies it locally (DeviceCert.verify): the root signed it, it names the account that root derives, it certifies the keys this app holds, and the state is the one sent.
  4. It asks the cloud manager which relay serves the account (GET /api/cloud/accounts/{account}/relays, proven by signing a fresh challenge with the device key).
  5. It learns the relay’s node key from POST /admin-api/tee/attest and logs in (/auth/challenge → /auth/token, auth_method: account_proof) for Bearer tokens. Admin reads and live events use those; writes are warrants the device signs and the relay spends (POST /admin-api/contexts/{ctx}/intents).
// Compose
val client = remember { MeroClient.create(context) }
LaunchedEffect(Unit) { client.restore() } // reconnect a saved session
MeroProvider(client) {
val state by client.state.collectAsStateWithLifecycle()
when {
!state.isAuthenticated -> LoginSheet(callbackUrl = "myapp://calimero-enrol")
state.signedInWithoutRelay -> NoRelayYet(state.relayNote) // redeem an invitation
else -> MyApp(client.relay!!)
}
}
// In the Activity whose intent filter receives the callback:
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
val url = intent.data?.toString() ?: return
lifecycleScope.launch { client.handleEnrolmentCallback(url) }
}

Without Compose, CloudAccount is the same flow step by step:

val account = CloudAccount(EncryptedPrefsSecureStore(context))
val walletUrl = account.beginEnrolment("myapp://calimero-enrol") // open in a Custom Tab
// …the callback arrives…
val session = account.completeEnrolment(callbackUrl) // verified, relay found
val connection = account.connect(session!!)
connection.relay.execute(contextId, "send_message", buildJsonObject { put("text", "hi") })
val messages = connection.relay.call(contextId, "get_messages") // read by query, 409 → warrant

Any absolute URL the app receives works on the SDK side: an https App Link (verified with Digital Asset Links) or an app scheme such as myapp://calimero-enrol. The wallet appends the answer as a fragment, so the callback itself must not contain #.

A new account is a member of nothing, so no relay serves it yet. That is still a successful sign-in: state.signedInWithoutRelay is true and state.relayNote explains it. Redeeming an invitation admits the account and gives it a relay:

client.joinWithInvitation(namespaceId, invitation) // signs a MemberJoined op, POST …/admit

The login statement names the relay’s node key, which mero-js learns from the relay’s TEE attestation and verifies as a TDX DCAP quote. On Android this sits behind RelayKeyVerifier. The default TlsRelayKeyVerifier trusts the TLS connection and checks that the attestation binds this request’s nonce and the named key; it does not verify the quote itself yet. Supply your own verifier to CloudAccount(relayKeyVerifier = …) to tighten that. Warranted writes do not depend on the node key.

For servers, tools and tests that talk to a node you run, exchange a username and password for a token bundle. (Not offered by the Compose UI kit.)

mero.authenticate(Credentials(username = "alice", password = "s3cr3t"))
val authed = mero.isAuthenticated // true

You can also put credentials in the config and call authenticate() with no argument:

val mero = Mero(
MeroConfig(
baseUrl = "https://your-node.example",
credentials = Credentials(username = "alice", password = "s3cr3t"),
)
)
mero.authenticate() // uses config.credentials, else throws MeroStateException

Refresh is reactive: the SDK never refreshes proactively (the server rejects a refresh while the access token is still valid). A 401 with x-auth-error: token_expired drives a single refresh and one retry — invisible to the caller.

A single-flight RefreshCoordinator inside Mero serialises refresh so concurrent requests that all hit a 401 share one in-flight refresh. Because refresh tokens are single-use (rotated on every refresh), it re-reads the token store first — so if another process or a background worker already rotated the token, that newer bundle is adopted instead of replaying a consumed one (which would revoke the whole token family). Supplying a refreshLockFile in the config extends that guarantee across processes via an exclusive file lock.

Section titled “Hosted SSO for a self-hosted node (deep link)”

For hosted login, open the node’s login URL, and feed the callback deep link back in — the native analog of the web redirect flow. On Android, SsoLauncher opens the URL in a Chrome Custom Tab:

import com.calimero.mero.Mero
import com.calimero.mero.auth.AuthLoginOptions
import com.calimero.mero.auth.SsoLauncher
import com.calimero.mero.TokenData
// 1. Launch the hosted login (opens a Custom Tab).
SsoLauncher.launch(
context,
nodeUrl = "https://your-node.example",
options = AuthLoginOptions(callbackUrl = "myapp://auth-callback", mode = "login",
permissions = listOf("admin")),
)
// ⚠️ A *scoped* permission list (anything other than "admin") must name
// "context:subscribe" since core 0.11.0-rc.41, or /sse and /ws answer 403.
// It is not implied by "context". See /guides/events/.
// 2. In the deep-link Activity that receives myapp://auth-callback:
val callback = Mero.parseAuthCallback(intent.dataString ?: "")
if (callback != null) {
mero.setTokenData(
TokenData(callback.accessToken, callback.refreshToken, expiresAt = 0L)
)
// callback also carries applicationId, contextId, contextIdentity, nodeUrl
}

parseAuthCallback reads the URL hash fragment (access_token, refresh_token, application_id, context_id, context_identity, node_url) and returns null if no access token is present. Passing expiresAt = 0L tells setTokenData to derive the real expiry from the JWT. Both SSO helpers are also standalone functions in com.calimero.mero.auth.

Pass a tokenStore in the config so tokens survive app launches. EncryptedPrefsTokenStore (AndroidX Security, hardware-backed key) is the secure default; the memory store is ephemeral.

// Secure, persisted across launches — AndroidX EncryptedSharedPreferences
val mero = Mero(
MeroConfig(
baseUrl = "https://your-node.example",
tokenStore = EncryptedPrefsTokenStore(context),
)
)
// Isolate multiple nodes with distinct files:
val store = EncryptedPrefsTokenStore(context, fileName = "mero_tokens_prod")
// Ephemeral (the default when tokenStore is null)
val ephemeral = Mero(
MeroConfig(baseUrl = "https://your-node.example", tokenStore = MemoryTokenStore())
)

Implement TokenStore (getTokens / setTokens / clear, all synchronous) to back tokens with anything else — see the reference.

mero.isAuthenticated // Boolean — is a bundle present?
mero.getTokenData() // TokenData? (debugging / handoff)
mero.clearToken() // drop the bundle + clear the store (local only)
mero.logout() // clearToken() + best-effort server revoke