JWT access token and refresh token
Access Token
The simplest method to use JWT is to read it from a cookie. Let's start with some boilerplate where we have token and some_data to be loaded from API.
use vertigo::{dom, LazyCache, Resource, Value}; #[derive(Clone, PartialEq)] struct Data; let token: Value<Option<String>> = Value::new(None); let some_data: Value<Option<LazyCache<Data>>> = Value::new(None); let token_span = token.render_value(|token| match token { Some(token) => dom! { <span>{token}</span> }, None => dom! { <i>"None"</i> }, }); let data_state_str = some_data.render_value(|data| match data { Some(resource) => resource.to_computed().render_value(|res| match res { Resource::Loading => dom! { <i>"Loading"</i> }, Resource::Ready(_) => dom! { <i>"Data loaded"</i> }, Resource::Error(err) => dom! { <i>"Error: " {err}</i> }, }), None => dom! { <i>"None"</i> }, }); dom! { <div> <p> "Current token: " {token_span} </p> <p> "Data state: " {data_state_str} </p> </div> }
Reading it from a cookie can be done through the Driver:
use vertigo::{get_driver, Value}; let cookie = get_driver().cookie_get("jwt_token"); let token: Value<Option<String>> = Value::new((!cookie.is_empty()).then_some(cookie));
Note that the cookie must not be HttpOnly to be readable this way, and that during server-side rendering cookie_get always returns an empty string, so the token will be None on the server.
Reading the data using token:
let load_data = bind!(some_data, || { let data_resource = LazyCache::new( get_driver() .request_get("https://example.com/api/data") .bearer_auth(token.to_computed()) .ttl_minutes(1), |status, body| { if status == 200 { Some(body.into::<Data>()) } else { None } }, ); some_data.set(Some(data_resource)); });
If the API is served from the same origin as the app, there's another option: keep the token in an HttpOnly cookie and let the browser send it. Use relative URLs (f. ex. /api/data). During server-side rendering they're resolved against the address of the server itself (which can forward them to the API with --proxy, see "Runtime configuration"), and sent with the cookies of the browser which requested the page.
Refresh Token
An access token is valid for a short time. When it expires, the API responds with 401, and the app should get a new one using a refresh token. The refresh token is best kept in an HttpOnly cookie set by the server (with Path limited to the refresh endpoint), so the browser sends it, but no script can read it.
Let's keep the access token in a store, together with the logic of refreshing it:
use std::rc::Rc; use vertigo::{ dev::ValueMut, get_driver, store, transaction, AutoJsJson, RequestBuilder, Value, }; #[derive(AutoJsJson)] pub struct TokenResponse { access_token: String, } #[derive(Clone)] pub struct Auth { pub token: Value<Option<String>>, refreshing: Rc<ValueMut<bool>>, } impl Auth { /// To be called when the API responds with 401 pub fn token_expired(&self) { // Nothing to refresh without a token, and one refresh at a time is enough let has_token = transaction(|ctx| self.token.get(ctx)).is_some(); if !has_token || self.refreshing.get() { return; } self.refreshing.set(true); let auth = self.clone(); get_driver().spawn(async move { // The browser sends the refresh token cookie along let response = RequestBuilder::post("/api/auth/refresh").call().await; let new_token = response .into(|status, body| (status == 200).then(|| body.into::<TokenResponse>())) .ok() .map(|response| response.access_token); auth.refreshing.set(false); // None if the refresh failed - time to log in again auth.token.set(new_token); }); } } #[store] pub fn auth() -> Auth { Auth { token: Value::new(None), refreshing: Rc::new(ValueMut::new(false)), } }
Requests using the token report when it expired:
use vertigo::{dom, AutoJsJson, RequestBuilder}; #[derive(AutoJsJson, Clone, PartialEq)] pub struct Data { secret: String, } let data = RequestBuilder::get("/api/data") .bearer_auth(auth().token.to_computed()) .lazy_cache(|status, body| match status { 200 => Some(body.into::<Data>()), 401 => { auth().token_expired(); Some(Err("Session expired".to_string())) } _ => None, }); data.render(|data| dom! { <p>"Secret: " {&data.secret}</p> })
When the token passed to bearer_auth changes, every LazyCache using it fetches its data again. So after a successful refresh the data loads with the new token automatically. If the refresh fails, the token becomes None and the request ends with "Session expired" without trying to refresh again.
Note that the response handler is called only when the response has a JSON body (or an empty one), so the API should respond to unauthorized requests like that, not with a plain text.