Stackure

Stackure Rust SDK

Protect axum, tonic and hyper services with tower middleware.

Add the crate. Set the app ID and secret. Add the layer. For how sign-in works, see the overview. For the HTTP calls the SDK makes, see the API reference.

Install

[dependencies]
stackure = "1"

It needs Rust 1.85 or later, for the 2024 edition. The default axum feature reads the client's address and the full request path from axum. If you don't use axum, you can turn it off:

stackure = { version = "1", default-features = false }

See the API docs and the source.

Configure

export STACKURE_APP_ID=...
export STACKURE_APP_SECRET=...
export STACKURE_BASE_URL=https://stackure.com

The SDK reads all 3 from the environment on every call, so no function takes them as arguments.

Protect a route

use axum::{Router, routing::get};

let app = Router::new()
    .route("/", get(home))
    .route("/admin", get(admin))
    .layer(stackure::auth());

In axum, layer covers the routes added before it. auth is a tower layer, so it also fits tonic and hyper stacks whose request body is an http_body::Body of Bytes and whose request and response bodies implement From<Bytes>, as axum::body::Body does.

Read the signed-in user in your handler:

use axum::Extension;
use stackure::User;

async fn admin(Extension(user): Extension<User>) -> String {
    format!("{} {}", user.user_email, user.account_id)
}

Without axum, call stackure::user_from_request(&parts). It returns Option<&User>: None when neither auth nor mcp ran. User has user_id, account_id (the user's organization), user_email, user_first_name, user_last_name, user_is_app_admin and user_teams. When the app is shared with partner organizations, their users sign in too, each with their own account_id.

A request without a valid session doesn't reach your handler:

{"error":"Unauthorized","message":"Valid authentication required","sign_in_url":"https://stackure.com/sign-in/magic-link?app_id=YOUR_APP_ID"}

When the check can't be completed, for example because Stackure can't be reached, a setting is missing or the app secret is wrong, the request gets a 500 with "message":"Authentication verification failed" and no redirect. The SDK prints the cause to standard error.

Every request with a session cookie is checked with Stackure, so signing out or losing access takes effect on the next request. A request without a well-formed token is turned away without a call to Stackure. Sessions aren't tied to the browser's user agent or IP address. The SDK forwards User-Agent and the client's address as X-Forwarded-For, but Stackure doesn't use them to check the session.

The client's address is the first X-Forwarded-For entry, or else the peer address. With the axum feature the SDK reads the peer address from ConnectInfo<SocketAddr>, so serve the app like this:

axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await?;

Without the feature, the SDK reads a SocketAddr from the request extensions.

Sign-in handoff

You don't write this part. After the user signs in, Stackure sends the browser to your app's registered URL with a form POST carrying session_token, a token that works only for your app. auth checks it with Stackure, stores it in a cookie on your domain and redirects to the same URL with a 303, so the page loads again as a GET.

People can open the app once an app admin gives it to everyone in the organization or to one of their teams. App admins and the owner can always open it. Anyone else who signs in sees "You don't have access to this app."

Protect an MCP endpoint

use axum::{Router, routing::{any, get}};

let app = Router::new()
    .route("/", get(home))
    .layer(stackure::auth())
    .route("/mcp", any(mcp_server).layer(stackure::mcp()));

mcp_server is your MCP server's handler. It's added after the auth layer, so /mcp sits behind mcp alone. AI clients such as Claude, Claude Code, VS Code and Cursor sign users in through Stackure and send the credential it issues as a bearer token. mcp checks every request with Stackure using the same app ID and secret. There's no extra setup.

Teams and app admins

Every User from auth or mcp carries 2 facts from the user's organization:

Stackure defines no permissions inside your app. Your app decides what these facts mean.

List who can open the app

let opts = stackure::DirectoryOptions { query: Some("ada".into()), ..Default::default() };
let page = stackure::directory_users(&parts, &opts).await?;
// page.items: DirectoryUser { user_id, user_email, user_first_name, user_last_name }
// page.next_cursor, page.total

let teams = stackure::directory_teams(&parts, &Default::default()).await?;
// teams.items: Team { team_id, team_name }

For pickers and sharing, directory_users lists the users in the caller's organization who can open your app and directory_teams the teams that have it. Each call returns one page, sorted by name.

Verify without middleware

let result = stackure::verify(&parts).await;
if !result.authenticated {
    let error = result.error.unwrap();
    // error.code, error.message, error.sign_in_url
}
// result.user

verify never returns an error. No valid session is a result with code 401 and a sign_in_url. A failed check is a result with code 500. The SDK prints the cause to standard error. verify doesn't take the sign-in handoff, so the registered URL still needs auth.

stackure::validate_session(&parts) returns the raw result instead: a Result<Session, StackureError>, where Session has authenticated, user and sign_in_url.

let response = stackure::send_magic_link("user@example.com").await?;
// response.message

Stackure emails the user a sign-in link for your app. It works for 15 minutes. Opening it asks the user to confirm with Continue on Stackure. It then signs them in and, if they can open the app, sends them to it. The response is the same whether or not the address belongs to someone. A malformed address is StackureError::Validation before any call. Sending is limited to 3 requests per minute and 5 requests per hour for each email and IP address. Past that, Stackure answers 429, which comes back as StackureError::Network.

Sign out

use axum::{Router, response::Response, routing::any};
use http::request::Parts;

async fn logout(parts: Parts) -> Response {
    stackure::logout(&parts).await
}

let app = Router::new().route("/logout", any(logout));
<form method="post" action="/logout"><button>Sign Out</button></form>

Errors

validate_session, directory_users, directory_teams and send_magic_link return StackureError on failure. Match on the variant, or call .code() for the same string the other Stackure SDKs expose as code: the variant name in lowercase, such as validation.

use stackure::StackureError;

match stackure::send_magic_link(email).await {
    Ok(response) => {}
    Err(StackureError::Validation(m)) => {}
    Err(StackureError::Auth(m)) => {}
    Err(StackureError::Forbidden(m)) => {}
    Err(StackureError::Timeout(m)) => {}
    Err(StackureError::Network(m)) => {}
}
VariantMeaning
ValidationA malformed email, no app secret or an app ID that's unset or isn't a UUID. Caught before any request.
AuthStackure answered 401, for example for a wrong app secret. directory_users and directory_teams also return it without a valid session.
ForbiddenStackure answered 403.
TimeoutThe call ran past its 2-second deadline.
NetworkEverything else: no connection, a redirect, a 429, another 4xx, a 5xx after the retry or a response the SDK can't read.

.message() returns the message alone. Printed with {}, an error reads stackure: code: message.

auth, mcp, verify and logout don't return errors. auth and verify report a failed check as 500 and mcp as 503. logout always redirects.

Timeouts and limits

Dependencies

Rust's standard library has no HTTP client and no TLS, so unlike the Go, JavaScript and Python SDKs this one can't have zero dependencies. It builds directly on hyper and rustls, the stack axum and tonic already run on, rather than on a higher-level client. In a plain axum app it adds 18 crates on Linux and 22 on macOS.

TLS uses the operating system's root certificates. Where there are none, such as in a container image without a CA bundle, every call fails with StackureError::Network.

Releases

Releases are published from main to crates.io automatically, versioned 1.YYYYMMDD.N and tagged v1.YYYYMMDD.N. Each one is published through crates.io trusted publishing and carries a GitHub build-provenance attestation. Report a vulnerability through GitHub Security Advisories.