Skip to content

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.

The shortest path is MeroKitUI: one “Continue with Calimero” button.

import MeroKitUI
@main
struct 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.

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 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.

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 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.

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 // true

You 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.credentials

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).

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.

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 Keychain
let 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.

await mero.isAuthenticated // Bool — is a bundle present?
await mero.currentTokenData() // TokenData? (debugging / handoff)
await mero.clearToken() // drop the bundle + clear the store
await mero.logout() // clearToken() + close() — full sign-out