Signing in with Rust
The Rust SDK, sourceorigin-auth, signs users in to an axum app. Its axum feature
mounts the login routes, keeps each user’s session in a sealed cookie, and hands
your handlers the signed-in user. Your app stores nothing.
The code on this page comes from the SDK’s example app,
examples/notes.rs.
Before you start
-
Register a Web app with a backend. The login routes refuse a browser or native app.
-
In its settings, add the redirect URI
https://<your app>/auth/callback. Every login requestsoffline_access, so the session can refresh its access token: tick theoffline_accessscope, or sign-in fails, and Allow refresh tokens, or each session lasts only as long as its first access token, five minutes. -
Generate a cookie key, which seals the session cookies:
openssl rand -base64 32Keep it secret, like the client secret, and give every replica the same one. Changing it signs every user out.
-
Add the SDK to your app’s
Cargo.toml:[dependencies] sourceorigin-auth = { git = "https://github.com/sourceorigin-dev/sourceorigin", features = ["axum"] } axum = "0.8" tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] }
Sign users in
Discover the issuer, then build the login routes from your app’s registration and the cookie key:
#![allow(unused)]
fn main() {
let issuer =
env::var("SOURCEORIGIN_ISSUER").unwrap_or_else(|_| "https://auth.sourceorigin.dev".into());
let issuer = Issuer::discover(&issuer).await?;
let config = ClientConfig {
client_id: env::var("NOTES_CLIENT_ID")?.parse()?,
credentials: Credentials::Secret(env::var("NOTES_CLIENT_SECRET")?.into()),
redirect_uri: env::var("NOTES_REDIRECT_URI")?.parse()?,
};
// Sessions live in the browser, sealed with this key: every replica uses the same.
let key = CookieKey::from_base64(&env::var("NOTES_COOKIE_KEY")?)?;
let auth = LoginRoutes::new(issuer.client(config), key)?.scopes("profile".parse()?);
}
scopes lists what to request besides openid and offline_access, which every
login requests. Then nest the routes at /auth, and add the layer around every
route that reads the session. ended, the sessions auth ended, is the state of
home below:
#![allow(unused)]
fn main() {
let app = Router::new()
.route("/", get(home))
.with_state(ended)
.nest("/auth", auth.router())
.layer(auth.layer())
.nest("/api", api);
}
The router serves:
GET /auth/login?return_to=/pagesends the browser to auth, and back to/pageonce signed in.return_tomust be a path on your app.GET /auth/callbackcompletes the sign-in and sets the session cookie.POST /auth/logoutsigns the user out.
To mount the routes elsewhere, nest them there and tell LoginRoutes::mounted_at.
after_login sets where a sign-in lands when it came from no page, / by default.
The session lives in the __Host-sourceorigin-<client ID>-session cookie, which is
Secure, HttpOnly and SameSite=Lax, so scripts can’t read its tokens. It
lasts 12 hours, or what session_lifetime says. It ends sooner when auth refuses
its refresh token, such as after the user signs out at auth. A browser can have
three sign-ins in progress at once, such as in several tabs.
Read the signed-in user
Take SignedIn in a handler that needs a user:
#![allow(unused)]
fn main() {
/// Auth sessions that back-channel logouts ended. Kept in the app's database in
/// practice, so every replica sees it.
type Ended = Arc<Mutex<HashSet<SessionId>>>;
/// `GET /`: signs the user in first, and refuses a session auth ended.
async fn home(State(ended): State<Ended>, user: SignedIn) -> Result<String, StatusCode> {
let ended = ended.lock().unwrap_or_else(PoisonError::into_inner);
if user.sid().is_some_and(|sid| ended.contains(&sid)) {
return Err(StatusCode::UNAUTHORIZED);
}
Ok(format!("Hello {}", user.name()))
}
}
A browser that opens the page signed out goes through login and comes back to it.
Any other request without a session, a fetch included, gets 401.
MaybeSignedIn gives an Option instead, for pages that work either way.
SignedIn holds the user’s name(), picture(), subject(), sid() and the ID
token’s claims(). login().access_token() is the access token, for calling an
API as the user: the layer refreshes it before it expires.
Requests other than GET, HEAD, OPTIONS and TRACE must come from your app’s
own pages, or they get 403, so your handlers need no CSRF token. An endpoint
other sites call with the session cookie takes CrossOrigin<SignedIn>, and guards
against cross-site request forgery itself.
Sign users out
Post a form to the logout route from one of your pages:
<form method="post" action="/auth/logout"><button>Sign out</button></form>
The route revokes the session’s refresh token, clears the cookie and sends the
browser to auth, which asks Ready to sign out?. Sign out also ends the
user’s Origin Account session in that browser, and Keep my session keeps it. Auth
then sends the browser to the URL you gave LoginRoutes::after_logout, which must
be one of the app’s post-logout URIs. Without one, auth shows its own page.
End sessions auth ended
A session learns that auth ended it at its next refresh, within five minutes. An app that must stop sooner handles back-channel logout:
#![allow(unused)]
fn main() {
let ended = Ended::default();
let recorded = ended.clone();
let auth = auth.on_backchannel_logout(move |logout| {
// Auth's logout tokens always name the session that ended.
if let LogoutScope::Session(sid) = logout.scope() {
recorded
.lock()
.unwrap_or_else(PoisonError::into_inner)
.insert(*sid);
}
async { Ok::<_, Infallible>(()) }
});
}
The SDK keeps no sessions, so your handler records the session that ended, and
your app refuses it, as home does above with user.sid(). Keep the record where
every replica sees it, such as your database. Auth may deliver a logout twice, so
the handler must accept a repeat. Then register
https://<your app>/auth/backchannel-logout as the app’s Back-channel logout
URI.
Ask for a recent passkey
A page that needs a stronger or more recent sign-in returns the StepUp from
SignedIn::require::<P>(), for an AuthenticationPolicy P that names the
requirement. When an API you call answers with an
insufficient_user_authentication challenge, parse its WWW-Authenticate header
as a BearerChallenge, and return user.step_up(challenge.step_up).
Either way, the browser goes through login and comes back to the same page, which
tries again. If the page asks again on that return, the answer is 403 rather than
another sign-in. A step-up that signs in another account fails with 400, and
keeps the session. Step-up authentication explains how to choose the
requirement.
Run the sign-in yourself
Outside axum, Client, from issuer.client(config), runs the authorization code
flow for either kind of app. authorize gives the URL to send the browser to, and
a PendingLogin to keep server-side. complete checks the callback, then
validates the ID token. refresh, revoke, userinfo and logout_url cover the
rest of a session, and logout_token validates a back-channel logout token.
Check that it works
- Open a page whose handler takes
SignedIn. The browser goes to auth’s sign-in page, then the first time to its consent page. - Sign in. The browser comes back to the page, which shows you signed in.
- Sign out with the logout form, and confirm with Sign out at auth. Opening the page again asks you to sign in again.
Troubleshooting
LoginRoutes::newfails withPublicClient: the client has no secret. Register a web app with a backend.- A handler answers
500: the route is missingauth.layer(). - Sign-in answers
502, “Sign-in could not start”: auth refused the sign-in request, and the message says why, such as a scope the app may not request:offline_accesswhen its scope is left unticked. - Sign-in answers
400, “This sign-in has no pending login”: the browser lost the login’s cookie, or opened the callback twice. Start again from/auth/login. - Every user is signed out after a deploy: the replicas use different cookie keys, or the key changed.
- Sign-out answers
502: auth could not revoke the session’s refresh token within five seconds. The session is kept, so the user can try again. - A page answers
503: the access token expired and auth could not renew it. The session is kept, and the next request tries again.