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.
Sign in with Calimero (mobile)
Section titled “Sign in with Calimero (mobile)”The flow mirrors mero-react’s Cloud tab, without a browser page of its own:
- 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. - 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. - 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 thestateis the one sent. - 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). - It learns the relay’s node key from
POST /admin-api/tee/attestand 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).
// Composeval 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 foundval 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 → warrantThe callback URL
Section titled “The callback URL”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 #.
Signed in, without a relay
Section titled “Signed in, without a relay”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 …/admitThe relay’s node key
Section titled “The relay’s node key”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.
Self-hosted nodes
Section titled “Self-hosted nodes”Direct credentials
Section titled “Direct credentials”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 // trueYou 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 MeroStateExceptionThe token lifecycle
Section titled “The token lifecycle”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.
Hosted SSO for a self-hosted node (deep link)
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.Meroimport com.calimero.mero.auth.AuthLoginOptionsimport com.calimero.mero.auth.SsoLauncherimport 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.
Persistence
Section titled “Persistence”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 EncryptedSharedPreferencesval 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.
Inspecting and clearing auth state
Section titled “Inspecting and clearing auth state”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