Cookbook
Auth
Where a token lives, when it is refreshed and what logging out forgets are rules of the app, not of three UIs. Written once in the core, they hold on iOS, Android and the web.
The recipe#
- The session is a signal.
Auth.sessionisSignedOutorSignedIn. The root view switches on it; nothing else decides which screen shows. - Tokens are secrets, not state. They go to the
SecureStoreport (Keychain, Android Keystore, WebCrypto) and never into a signal, because every signal is mirrored into each platform's memory. - A 401 is not an error.
authedadds the token and, on a401, refreshes once and retries. A second request that meets the same401waits for the refresh in flight. A refresh the server refuses signs the user out, wherever the request came from. - Queued writes use today's token. An offline write replayed an hour later goes through
authedtoo, so it carries the token that is current then. - Logout forgets. Both tokens, the persisted cache, the session. It is refused with a typed
PendingWriteswhile writes wait in the offline queue: they would go out as whoever signs in next.
Show the Rust
/// Who is signed in.
#[undra::api]
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Session {
/// Nobody: show the login screen.
SignedOut,
/// A user with a session the server accepted.
SignedIn {
/// The display name the server returned at sign-in.
user: String,
},
}
/// The login screen's store: the session and whether a call is running.
#[undra::store(restore = "Self::assemble")]
pub struct Auth {
ctx: WeakCtx,
session: Signal<Session>,
busy: Signal<bool>,
}
/// Signs in with an email and a password: the tokens go to the secure store, the session to
/// the UI. A wrong password is `BadCredentials`, not a network error.
pub async fn sign_in(&self, email: String, password: String) -> Result<(), AuthError> {
let ctx = self.ctx.upgrade().map_err(|_| AuthError::Closed)?;
let _busy = Busy::start(&self.busy);
let url = net::url(&ctx, "/auth/login")?;
let body = serde_json::json!({ "email": email, "password": password });
let response = net::send(&ctx, net::post_json(url, &body)).await?;
let tokens: Tokens = match response.status {
401 | 403 => return Err(AuthError::BadCredentials),
_ => net::json(&net::ok(response)?)?,
};
save_tokens(&ctx, &tokens).await?;
self.session.set(Session::SignedIn { user: tokens.user });
Ok(())
}/// Sends `request` as the signed-in user: with the access token, and with one refresh and one retry
/// if the server answers `401`. A refresh the server refuses signs the user out and fails with
/// [`AuthError::SessionExpired`].
pub async fn authed(ctx: &Ctx, request: HttpRequest) -> Result<HttpResponse, AuthError> {
for attempt in 0..2 {
let seen = state(ctx).epoch.load(Ordering::SeqCst);
let Some(token) = secret(ctx, ACCESS).await? else {
return Err(AuthError::SessionExpired);
};
let signed = request
.clone()
.with_header("Authorization", format!("Bearer {token}"));
let response = net::send(ctx, signed).await?;
if response.status != 401 {
return Ok(response);
}
if attempt == 0 {
refresh(ctx, seen).await?;
}
}
forget(ctx).await;
Err(AuthError::SessionExpired)
}
/// Refreshes the tokens, unless somebody already did since `seen`; waits if somebody is doing it.
async fn refresh(ctx: &Ctx, seen: u64) -> Result<(), AuthError> {
let st = state(ctx);
if st.epoch.load(Ordering::SeqCst) != seen {
return Ok(());
}
if st.refreshing.swap(true, Ordering::SeqCst) {
// The core runs on one thread, so this flag is race free. Waiting on the Timer port keeps
// it deterministic in a test: the fake clock decides when the poll runs.
while st.refreshing.load(Ordering::SeqCst) {
ctx.sleep(Duration::from_millis(20)).await;
}
return Ok(()); // the retry shows how the refresh went
}
let _running = Flag(&st.refreshing);
let Some(refresh_token) = secret(ctx, REFRESH).await? else {
forget(ctx).await;
return Err(AuthError::SessionExpired);
};
let url = net::url(ctx, "/auth/refresh")?;
let response = net::send(
ctx,
net::post_json(url, &serde_json::json!({ "refresh": refresh_token })),
)
.await?;
match response.status {
200..=299 => {
let tokens: Tokens = net::json(&response.body.0)?;
save_tokens(ctx, &tokens).await?;
Ok(())
}
// The refresh token was refused: this session is over.
401 | 403 => {
forget(ctx).await;
Err(AuthError::SessionExpired)
}
code => Err(NetError::Status { code }.into()),
}
}/// Forgets the user: both tokens, the persisted cache, and the session the UI shows.
async fn forget(ctx: &Ctx) {
let store = ctx.secure_store();
// Best effort: a store that cannot delete is already unusable, and the session must still end.
let _ = store.delete(ACCESS.to_owned()).await;
let _ = store.delete(REFRESH.to_owned()).await;
let kv = ctx.kv();
if let Ok(keys) = kv.list(CACHE_PREFIX.to_owned()).await {
for key in keys {
let _ = kv.delete(key).await;
}
}
let st = state(ctx);
st.epoch.fetch_add(1, Ordering::SeqCst);
let session = st
.session
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clone();
if let Some(session) = session {
session.set(Session::SignedOut);
}
}
/// Signs out: tells the server (best effort), forgets both tokens and the persisted cache and
/// shows the login screen. Refused with `PendingWrites` while writes wait in the
/// offline queue.
pub async fn sign_out(&self) -> Result<(), AuthError> {
let ctx = self.ctx.upgrade().map_err(|_| AuthError::Closed)?;
let pending = ctx.query().pending_mutations();
if pending > 0 {
return Err(AuthError::PendingWrites {
count: u32::try_from(pending).unwrap_or(u32::MAX),
});
}
let _busy = Busy::start(&self.busy);
if let Ok(url) = net::url(&ctx, "/auth/logout") {
// The server may be unreachable: signing out must not depend on it.
let _ = authed(&ctx, HttpRequest::post(url, Vec::new())).await;
}
forget(&ctx).await;
Ok(())
}On the platform#
The root view switches on the session, and a failed sign-in is a case, not a string to parse.
Show the Swift, Kotlin and TypeScript
struct Root: View {
let auth: Auth
var body: some View {
switch auth.session {
case .signedOut: LoginView(auth: auth)
case .signedIn(let user): HomeView(user: user)
}
}
}
@MainActor func logIn(_ auth: Auth, email: String, password: String) async -> String? {
do {
try await auth.signIn(email: email, password: password)
return nil
} catch AuthError.badCredentials {
return "Wrong email or password"
} catch {
return error.localizedDescription
}
}fun label(session: Session): String = when (session) {
Session.SignedOut -> "Sign in"
is Session.SignedIn -> session.user
}
suspend fun logIn(auth: Auth, email: String, password: String): String? =
try {
auth.signIn(email, password)
null
} catch (e: AuthError.BadCredentials) {
"Wrong email or password"
} catch (e: UndraException) {
e.message
}export function Root({ auth }: { auth: Auth }) {
const session = useSignal(auth.session);
return session.kind === "signedOut" ? <Login auth={auth} /> : <Home user={session.user} />;
}
export async function logIn(auth: Auth, email: string, password: string): Promise<string | null> {
try {
await auth.signIn(email, password);
return null;
} catch (error) {
return error instanceof AuthError.BadCredentials ? "Wrong email or password" : String(error);
}
}What the tests prove#
- Sign-in puts the tokens in
SecureStoreand nothing in plain storage. - A
401costs exactly one refresh and the caller never sees it; a refused refresh clears both tokens, the persisted cache and the screen. - A request that meets a refresh in progress waits instead of refreshing again.
- A session left by the last run resumes (refreshing on the way); sign-out is refused while a write is queued.
Limits#
- The query client has no
clear()yet. Sign-out deletes the persisted entries and the recipe keys cached data by user (profile:{user}), so the next person never reads the last one's entry; in-memory entries leave with their observers. - The core cannot show a login web view or an OAuth sheet: that is the platform's. It hands the code to
sign_inlike any other value.
Source: examples/cookbook/core/src/auth.rs. The sample app, Fieldbook, uses it.