Tibi

Auth

tibi serve has built-in sign-in through Strands Accounts (OAuth2 with PKCE). Turn it on in .tibi-config, and the signed-in user reaches frontmatter and API routes as ctx.user and the browser as Tibi.auth.user.

Setup

[auth] provider = "strands" mode = "optional" # or "required" (the default) public_paths = ["/@"] # only used in required mode

Secrets stay in the environment:

  • TIBI_STRANDS_OAUTH_CLIENT_ID and TIBI_STRANDS_OAUTH_REDIRECT_URI (register https://your.app/__tibi/auth/callback).
  • TIBI_STRANDS_OAUTH_SIGNING_KEY: 32+ bytes, signs the session cookie. Rotating it signs everyone out.
  • TIBI_STRANDS_OAUTH_CLIENT_SECRET for a confidential client; leave it unset for a public PKCE client.
  • Optional: TIBI_STRANDS_OAUTH_SCOPES (default openid profile offline_access), TIBI_STRANDS_OAUTH_SESSION_TTL_SECS, TIBI_STRANDS_OAUTH_ALLOW_USERS (user ids or usernames allowed to sign in).

Missing variables stop tibi serve at boot with the name of the variable. A variable that is set but empty counts as missing (deploy tools often pass unset variables through as empty strings). Rust hosts that know their own public URL can build the config with StrandsAuthConfig::from_env_with_redirect("https://your.app/__tibi/auth/callback"), which uses that callback when TIBI_STRANDS_OAUTH_REDIRECT_URI is unset.

Modes

  • required: every path outside public_paths needs a session. Pages redirect to sign-in and come back afterwards; requests under /api/ or asking for JSON get 401 {"error":"unauthenticated"} instead.
  • optional: every path renders for everyone. The user is attached when a session exists, so a public profile page and a private editor can live in one app and decide per page (redirect from frontmatter with ctx.redirect, or answer 401 from an API route).

The user

Strands Accounts gives apps the user's id, username and avatar, nothing more. Ask users directly for anything else.

---js if (!ctx.user) { ctx.redirect('/__tibi/auth/login?return_to=/edit'); return {}; } result = { name: ctx.user.username ?? 'there' } --- // pages/api/pages/index.ts export async function GET(ctx) { if (!ctx.user) ctx.error(401, 'Sign in first'); return listPagesFor(ctx.user.id); }

Routes the layer answers

  • /__tibi/auth/login?return_to=/path: start sign-in, then return to the path (local paths only).
  • /__tibi/auth/logout?return_to=/path: clear the session and revoke the refresh token.
  • /__tibi/auth/me: the profile, or null.
  • /__tibi/auth/token: { token, expiresAt, user } for browser calls to another backend.

In the browser

Tibi.auth.user // { id, username, avatar } or null Tibi.auth.signedIn await Tibi.auth.token() // JWT string, or null when signed out Tibi.auth.login() // to sign-in and back here Tibi.auth.logout('/')

token() returns the OIDC id_token: an RS256 JWT with issuer https://sessions.strands.gg and audience set to your client id. Configure the backend (Tether: an auth provider with that issuer, JWKS https://sessions.strands.gg/.well-known/jwks.json and the client id as audience) to verify it. Tokens are cached until 30 seconds before expiry. A 401 resolves to null; network errors and 5xx reject, so an outage is never mistaken for a sign-out.

The token lives as long as the Strands access token (an hour by default). When it is about to run out, the layer renews it with the refresh token and updates the session cookie. Requests renewing the same session at the same moment (two tabs, a page and its own fetches) share one renewal, so Strands rotating the refresh token can't sign anyone out.

If Strands refuses the refresh (the refresh token expired or was revoked), the session is over: /__tibi/auth/token clears the cookie and answers 401 {"error":"unauthenticated"}, and Tibi.auth reloads the page so it renders signed out. To handle that yourself, cancel the event:

addEventListener('tibi:signedout', (e) => { e.preventDefault(); Tibi.auth.login(); // straight back through sign-in instead });

Strands being unreachable or answering 5xx is 503 {"error":"unavailable"} and keeps the session, so token() rejects and the next call tries again.

The Tether plugin uses this automatically: it sends a fresh token on every call, retries once on a 401, and cycles its WebSocket shortly before the token it opened with expires, re-subscribing with catch-up.

Custom servers

Apps with their own main.rs can mount tibi_plugin_strands_auth::StrandsAuthLayer (or any layer) through tibi::serve_with_extension. Whatever inserts a tibi_core::AuthUser request extension feeds ctx.user.