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.
STACKURE_APP_IDis the app ID from the app's page in Stackure. Every call exceptlogoutneeds it and fails withStackureError::Validationwhen it's unset or isn't a UUID.STACKURE_APP_SECRETis shown once, when you register the app and each time you rotate its secret. It's sent asX-App-Secreton every call to Stackure except the sign-out call, which carries only the session token. A call that needs it fails withStackureError::Validationwhen it's unset. After a rotation, checks fail until you update it.STACKURE_BASE_URLis the Stackure address and defaults tohttps://stackure.com. Each region has its own address. This page is for https://stackure.com. The SDK sends every call there and accepts the sign-in handoff only from there.
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:
- A browser request, one whose
Acceptheader includestext/htmlbut notapplication/json, is redirected to Stackure sign-in with a302. - Any other request gets a
401with this body:
{"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.
- Serve the registered URL behind
auth, or the handoff never reaches the layer. The registered URL must use HTTPS. authtakes the token only from a POST of form data whoseOriginis the Stackure address. Bodies over 4 KB and tokens that don't check out are ignored.- The cookie is named
session. It'sHttpOnly,SameSite=LaxandPath=/. It lasts 7 days. It'sSecureonly when the request hasX-Forwarded-Proto: httpsor an absolutehttpsURI. A TLS listener in your own process isn't visible from the request, so set that header where TLS ends. - The session ends 1 day after the user signed in to Stackure, or sooner when they sign out or sign in again.
- Someone already signed in to Stackure is handed in without signing in again.
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.
- The user is attached as with
auth, soExtension<User>anduser_from_requestwork in your MCP handler. - A request that isn't signed in gets a
401with{"error":"unauthorized"}and aWWW-Authenticateheader that tells the AI client where to sign in. - A check that can't be completed gets a
503with{"error":"unavailable"}. The SDK prints the cause to standard error. mcpreads onlyAuthorization: Bearer. It never redirects and never reads or sets a cookie, so don't put the MCP route behindauthas well.- The endpoint must use HTTPS on the same host and port as the app's registered URL, unless an MCP URL is set for the app in Stackure. The SDK works out the endpoint's address from the request's scheme,
Hostheader and path, so behind a proxy pass the originalHostandX-Forwarded-Proto: httpsthrough. - Access ends when the user signs out, disconnects the client or loses access to the app. A connection also ends after 30 days unused or 90 days after it was made.
Teams and app admins
Every User from auth or mcp carries 2 facts from the user's organization:
user_is_app_admin: the user is an app admin or the owner, in charge of the organization's apps.user_teams: the teams they belong to, aVec<Team>ofteam_idandteam_name. Empty when there are none.
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.
querymatches a user's name, email and job title, or a team's name.limitis the page size: 100 by default, 200 at most.idsreturns only those IDs that can open the app, up to 200, to check or name people and teams you already store.- Pass
next_cursorascursorfor the next page. It'sNoneon the last one. totalis set on the first page when there are 10,000 matches or fewer.- Both use the request's session cookie, so call them from a route behind
auth. MCP bearer tokens aren't accepted. A request without a valid session getsStackureError::Auth.
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.
Send a sign-in link
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>
- Mount
logoutfor every method on its path, asanydoes here, so every request to it reaches the SDK. - Trigger it with a form or button that POSTs from your app's own page. That POST makes a server-side call that signs the user out of Stackure everywhere, on every app, device and AI client. It clears your cookie and redirects to Stackure with a
303. If the call fails, the redirect goes to the Stackure sign-out page, where the user can finish signing out. - A link or any other request goes to the Stackure sign-out page, where the user confirms. Nothing is called and the cookie stays.
- A POST counts as from your own page when
Sec-Fetch-Siteissame-origin. Without that header, it counts whenOriginhas the same host and port asHost, with anhttpsscheme if the request arrived over HTTPS. Requests that sendSec-Fetch-Site,OriginorHostmore than once don't count. This keeps a link on another site from signing your users out. logoutis async. Awaiting it always gives aResponse, never an error.
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)) => {}
}
| Variant | Meaning |
|---|---|
Validation | A malformed email, no app secret or an app ID that's unset or isn't a UUID. Caught before any request. |
Auth | Stackure answered 401, for example for a wrong app secret. directory_users and directory_teams also return it without a valid session. |
Forbidden | Stackure answered 403. |
Timeout | The call ran past its 2-second deadline. |
Network | Everything 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
- Every call has one 2-second deadline covering the connection, headers, body and retry.
- A 5xx response or a failed connection or read is retried once after 500 ms, only when more than 500 ms of the deadline is left.
- Timeouts are never retried. They're
StackureError::Timeout. - Redirects aren't followed. A 3xx response is
StackureError::Network. - Stackure accepts 1,000 requests per minute per app for session checks, as many again for MCP checks and as many again for
directory_usersanddirectory_teamstogether. Past that it answers 429, whichauthturns into a 500,mcpinto a 503 anddirectoryintoStackureError::Network.
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.