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#

Show the Rust
core/src/auth.rs
/// 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(())
}
core/src/auth.rs
/// 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()),
    }
}
core/src/auth.rs
/// 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
swift
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
    }
}
kotlin
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
    }
tsx
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#

Limits#

Source: examples/cookbook/core/src/auth.rs. The sample app, Fieldbook, uses it.

Edit this page on GitHub