Authentication
Mobile apps sign in with Calimero Cloud. The person approves this device on the Calimero wallet with their passkey, the wallet returns a certificate for a key that never leaves the device, and the app talks to the hosted relay that serves their account. There is no node URL and no password in the app.
Logging in to a node directly (credentials or the node’s hosted SSO) is still available for development nodes you run yourself; see Development nodes below.
Cloud sign-in
Section titled “Cloud sign-in”The shortest path is MeroKitUI: one “Continue with Calimero” button.
import MeroKitUI
@mainstruct MyApp: App { @StateObject private var client = MeroClient() // Keychain-backed by default var body: some Scene { WindowGroup { // Restores a stored session, shows LoginView otherwise, handles onOpenURL. MeroRootView(callbackScheme: "myapp").environmentObject(client) } }}client.signInWithCloud(callbackScheme:) opens the wallet in
ASWebAuthenticationSession (the system sheet, like “Sign in with Google”),
verifies the answer, finds the account’s relay and logs in there. Afterwards
client.account, client.relayURL, client.isSignedInWithoutRelay and
client.connection (a relay Mero + RelayClient) describe the session. If the
callback reaches the app some other way (a Universal Link, onOpenURL), pass it
to client.handleEnrolmentCallback(_:).
What the app checks before storing anything (DeviceEnrolment.completeEnrolment):
the state it sent came back; the certificate verifies (its root signed it, the
account is the one that root derives, the device id was minted for it); it
certifies this device’s signing and delivery keys; and the account and
device reported beside it are the ones it names. Any failure throws an
AccountError and nothing is saved.
Without MeroKitUI
Section titled “Without MeroKitUI”CloudSignIn is the UI-free core, usable from any UI framework:
import MeroKit
let cloud = CloudSignIn.keychain() // device keys, session, tokens in the Keychain
let walletURL = try await cloud.beginEnrolment(callbackURL: "myapp://enrol")// … present walletURL in ASWebAuthenticationSession (callbackURLScheme: "myapp") …let session = try await cloud.completeEnrolment(callbackURL: returnedURL)let connection = await cloud.connect(session)
// Writes: warranted intents through the relay, as the account.let result = try await connection.relay?.execute( contextId: contextId, method: "send_message", argsJson: ["message": "hi"])// Reads: the relay's query route (falls back to a warrant for non-views).let page = try await connection.relay?.query(contextId: contextId, method: "get_messages")// Admin reads and events: an ordinary Mero holding the relay's Bearer session.let spaces = try await connection.mero?.admin.listNamespaces()await cloud.restoreSession() returns the stored session on the next launch;
await cloud.signOut() retires the relay session (POST /auth/logout) and
forgets the account.
The callback URL
Section titled “The callback URL”The SDK accepts any callback: an app scheme (.scheme("myapp"), any iOS
version) or a verified https Universal Link (.https(host:path:), iOS 17.4+,
needs Associated Domains and an AASA file). The wallet shows the callback’s
origin as the app’s identity.
Signed in, no relay
Section titled “Signed in, no relay”A brand-new account is a member of nothing, so no relay serves it yet.
CloudSession.relayUrl is then nil and MeroClient.isSignedInWithoutRelay is
true: still signed in. Redeeming an invitation admits the account and gives it
a relay:
let (session, outcome) = try await cloud.join(namespaceId: ns, invitation: invitation)The relay’s node key
Section titled “The relay’s node key”The relay session is bound to the relay’s node key, learned from
POST {relay}/admin-api/tee/attest. The default TLSRelayKeyVerifier checks the
quote is bound to this request’s nonce and to the key the relay named, and
refuses mock quotes from hosted relays, but does not yet verify the TDX DCAP
quote signature; it trusts the TLS connection to the relay URL the Cloud
manager returned. Supply your own RelayKeyVerifier (or a
PinnedRelayKeyVerifier) in CloudConfig to tighten it. Warranted writes do not
depend on the node key; only Bearer reads and events do.
Development nodes
Section titled “Development nodes”For a node you run yourself, exchange its admin username and password for a
token bundle.
These are the admin credentials the node was initialised with — core
0.11.0-rc.17 creates the admin at merod init, so there is no first-login
setup code.
let tokens = try await mero.authenticate( Credentials(username: "alice", password: "s3cr3t"))let authed = await mero.isAuthenticated // trueYou can also put credentials in the config and call authenticate() with no
argument:
let mero = Mero(config: MeroConfig( baseURL: URL(string: "https://your-node.example")!, credentials: Credentials(username: "alice", password: "s3cr3t")))try await mero.authenticate() // uses config.credentialsThe 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.
Because Mero is an actor, concurrent requests that all hit a 401 share
one in-flight refresh. And because refresh tokens are single-use
(rotated on every refresh), the SDK re-reads the token store first — so if
another process or app extension already rotated the token, that newer bundle is
adopted instead of replaying a consumed one (which would revoke the whole token
family).
Hosted SSO (deep link)
Section titled “Hosted SSO (deep link)”For a development node’s own hosted login page, build the node’s login URL, present it in
ASWebAuthenticationSession, and feed the callback URL back in — the native
analog of the web redirect flow.
import AuthenticationServices
// 1. Build the login URL.let loginURL = Mero.buildAuthLoginUrl( nodeUrl: "https://your-node.example", options: AuthLoginOptions(callbackUrl: "myapp://auth-callback", mode: "login", permissions: ["admin"]))
// 2. Present it (ASWebAuthenticationSession), then on the callback URL:if let callback = Mero.parseAuthCallback(callbackURL.absoluteString) { await mero.setTokenData(from: callback) // 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 nil if no access token is present.
Both SSO helpers are static on Mero.
Persistence
Section titled “Persistence”Pass a tokenStore in the config so tokens survive app launches. The Keychain
store is the secure default; the memory store is ephemeral.
// Secure, persisted across launches — backed by the iOS/macOS Keychainlet mero = Mero(config: MeroConfig( baseURL: URL(string: "https://your-node.example")!, tokenStore: KeychainTokenStore()))
// Share tokens with an app extension via a Keychain access group:let shared = KeychainTokenStore(accessGroup: "TEAMID.group.myapp")
// Ephemeral (the default when tokenStore is nil)let ephemeral = Mero(config: MeroConfig( baseURL: URL(string: "https://your-node.example")!, tokenStore: MemoryTokenStore()))Conform to TokenStore (getTokens / setTokens / clear) to back tokens with
anything else — see the reference.
Inspecting and clearing auth state
Section titled “Inspecting and clearing auth state”await mero.isAuthenticated // Bool — is a bundle present?await mero.currentTokenData() // TokenData? (debugging / handoff)await mero.clearToken() // drop the bundle + clear the storeawait mero.logout() // clearToken() + close() — full sign-out