johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
//! Logging in to the pages.
//!
//! The server is a confidential OAuth client of the account's PDS, as the
//! gateway is, under a client ID of its own. It asks for the whole of
//! knotless's scope, so the pages can come to do what the command line does
//! without asking again, and keeps the session itself, in a directory of
//! its own for each account. That is apart from the sessions owners
//! delegate to the gateway: logging in to the pages delegates nothing.
//!
//! The browser holds only a cookie this server signed, naming the account,
//! HttpOnly and SameSite=Lax. A login is answered only in the browser that
//! started it, by a cookie naming its state, so a link to someone else's
//! approval cannot log a person in as them. Logging out revokes the session
//! and refuses every cookie signed before. An account taken down cannot log
//! in, and its cookies name no one while it stays down.
//!
//! Someone with no account makes one on the way: the request goes to the
//! PDS this site sends people to (`--sign-up-pds`) asking for its sign-up
//! page, and the account it names once approved is taken only if that
//! PDS's authorization server is the one asked, as atproto's OAuth says.
//! From there it is a login like any other.
//!
//! A browser can be logged in to several accounts at once: logging in to
//! another keeps the one before in a second cookie, of such signed cookies,
//! and switching swaps one of those for the one in use, with no login.
use crate::gateway::{self, Gateway};
use crate::{App, AppError};
use axum::Json;
use axum::extract::{Query, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::response::{Html, IntoResponse, Redirect, Response};
use knotless::crypto;
use knotless::env::Ctx;
use knotless::session;
use knotless::{identity, keys, oauth};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
/// Where the pages' OAuth client metadata is, their client ID.
pub const METADATA: &str = "/oauth/web-client-metadata.json";
const CALLBACK: &str = "/oauth/web-callback";
/// The cookie naming the account logged in.
pub const COOKIE: &str = "knotless_session";
/// The cookie the pages keep their theme in, and for how long, as
/// web/src/lib/theme.ts sets it.
const THEME: &str = "theme";
/// The cookie the pages keep a theme of the viewer's own colours in.
const PALETTE: &str = "palette";
/// The cookies the pages keep a pair's night half in, as they keep its day.
const NIGHT: &str = "theme-night";
const NIGHT_PALETTE: &str = "palette-night";
const YEAR: u64 = 365 * 24 * 60 * 60;
/// The cookie holding the other accounts a browser is logged in to: their
/// session cookies, `~` between.
pub const OTHERS: &str = "knotless_accounts";
/// How many other accounts a browser stays logged in to; logging in to one
/// more drops the one used least lately.
const MOST: usize = 4;
/// The cookie naming the login a browser started.
const STARTED: &str = "knotless_login";
/// How long a login lasts, in seconds.
const LASTS: u64 = 90 * 24 * 60 * 60;
/// How long approving a login at the PDS may take.
const TAKES: Duration = Duration::from_secs(15 * 60);
/// Where a login the app started comes back to: a universal link, which
/// only the app this site names in [`APP_ID`] can answer.
pub const APP_RETURN: &str = "/oauth/app-return";
/// The iOS app, by its Apple team and bundle.
const APP_ID: &str = "YN68LN9T7Z.dev.knotless.app";
/// How long the app has to trade the code it came back with for a session.
const APP_CODE_LASTS: Duration = Duration::from_secs(5 * 60);
/// How long a PKCE challenge is: SHA-256, base64url.
const CHALLENGE_LEN: usize = 43;
/// The file, in an account's directory, holding when it last logged out,
/// in microseconds since the epoch.
const LOGGED_OUT: &str = "logged-out";
/// A login gone to a PDS, waiting for the browser to come back.
pub struct Pending {
started: Instant,
request: oauth::Request,
/// The page to go back to.
back: String,
/// For a login the app started, the PKCE challenge it sent, which takes
/// the session back to the app rather than to a page.
challenge: Option<String>,
}
/// A login the app finished, waiting for the app to trade its code.
pub struct AppCode {
made: Instant,
did: String,
challenge: String,
}
/// What the session cookie says.
#[derive(Serialize, Deserialize)]
struct Claims {
kind: String,
did: String,
/// When it was signed, in microseconds since the epoch: a logout
/// refuses every cookie signed before it.
iat: u64,
exp: u64,
}
fn now() -> u64 {
keys::now_micros() / 1_000_000
}
/// Where the pages keep `did`'s session, mirrors and caches: its own, and
/// never a password.
pub fn ctx_for(app: &App, did: &str) -> Ctx {
let mut env = app.ctx.env.clone();
let dir = did.replace(':', "_");
env.config_dir = env.config_dir.join("web").join(&dir);
env.cache_dir = env.cache_dir.join("web").join(&dir);
env.identifier = Some(did.to_string());
env.password = None;
Ctx {
env,
http: app.ctx.http.clone(),
}
}
/// Where a space is synced as `did` from: the pages' session for them, or
/// with none, the one this gateway holds as they delegated to it, as an
/// organization syncs its own space.
pub fn syncer_ctx(app: &App, did: &str) -> Ctx {
let pages = ctx_for(app, did);
let held = session::load(&pages, did).is_none() && session::load(&app.ctx, did).is_some();
match held {
true => Ctx {
env: knotless::env::Env {
identifier: Some(did.to_string()),
password: None,
..app.ctx.env.clone()
},
http: app.ctx.http.clone(),
},
false => pages,
}
}
/// The pages' client: the gateway's key, under the pages' client ID.
fn client(app: &App, gw: &Gateway) -> Result<oauth::Client, AppError> {
gw.client_at(&app.ctx, METADATA)
}
/// Whether `did`'s login to the pages was asked for less than they ask
/// for now, so that logging in again grants the rest.
pub fn behind(app: &App, did: &str) -> bool {
let kept = session::load(&ctx_for(app, did), did);
kept.is_some_and(|s| oauth::behind(&s, &oauth::SCOPE))
}
/// The account a request's session cookie names, while the cookie holds
/// and the account has a session here: none for no cookie, a bad one, one
/// from before the account last logged out, one whose session went, or one
/// for an account taken down.
pub fn viewer(app: &App, headers: &HeaderMap) -> Option<String> {
app.gateway.as_ref()?;
held(app, &cookie(headers, COOKIE)?)
}
/// The account a session cookie's `token` names, while it holds, as
/// [`viewer`] says.
fn held(app: &App, token: &str) -> Option<String> {
let key = oauth::client_key(&app.ctx).ok()?;
let claims: Claims = crypto::verify_jwt(token, &key.public()).ok()?;
let held = claims.kind == "web" && holds(app, &claims.did, claims.iat);
held.then_some(claims.did)
}
/// Whether what was signed for `did` at `iat`, in microseconds since the
/// epoch, still holds: they have logged in here since, are not taken
/// down, and have not logged out since it was signed.
pub fn holds(app: &App, did: &str, iat: u64) -> bool {
let ctx = ctx_for(app, did);
let out: u64 = std::fs::read_to_string(ctx.env.config_dir.join(LOGGED_OUT))
.ok()
.and_then(|t| t.trim().parse().ok())
.unwrap_or(0);
iat > out && !app.taken_down(did) && session::load(&ctx, did).is_some()
}
/// The other accounts a request's cookies hold, with their tokens, the one
/// used last first: only those that still hold, and never `but`. Each is
/// there once, as every cookie of them set takes out the one it adds.
fn others(app: &App, headers: &HeaderMap, but: Option<&str>) -> Vec<(String, String)> {
let all = cookie(headers, OTHERS).unwrap_or_default();
let mut found: Vec<(String, String)> = Vec::new();
for token in all.split('~') {
let Some(did) = held(app, token) else {
continue;
};
if Some(did.as_str()) != but {
found.push((did, token.to_string()));
}
}
found
}
/// The other accounts the browser asking is logged in to, which it can
/// switch to: none for someone not logged in.
pub fn accounts(app: &App, headers: &HeaderMap) -> Vec<String> {
let Some(did) = viewer(app, headers) else {
return Vec::new();
};
let all = others(app, headers, Some(&did));
all.into_iter().map(|(did, _)| did).collect()
}
/// The `Set-Cookie` value keeping `others`, as many as a browser keeps.
fn set_others(gw: &Gateway, others: &[(String, String)]) -> HeaderValue {
let kept = others.iter().take(MOST).map(|(_, t)| t.as_str());
let value = kept.collect::<Vec<_>>().join("~");
set_cookie(
gw,
OTHERS,
&value,
[0, LASTS][usize::from(!value.is_empty())],
)
}
/// The value of the cookie `name` the request holds.
pub fn cookie(headers: &HeaderMap, name: &str) -> Option<String> {
let all = headers.get_all(header::COOKIE).into_iter();
let pairs = all
.filter_map(|v| v.to_str().ok())
.flat_map(|v| v.split(';'));
pairs
.filter_map(|pair| pair.trim().split_once('='))
.find(|(k, _)| *k == name)
.map(|(_, v)| v.to_string())
}
/// Whether a request a browser sent comes from a page of this site: one
/// with no `Origin` is no browser's, and holds no cookie of ours unless
/// whoever sent it has one.
pub fn same_site(headers: &HeaderMap) -> bool {
let Some(origin) = headers.get(header::ORIGIN) else {
return true;
};
let host = headers
.get("x-forwarded-host")
.or(headers.get(header::HOST))
.and_then(|h| h.to_str().ok());
let origin = origin.to_str().ok();
let origin = origin.and_then(|o| o.split_once("://")).map(|(_, h)| h);
origin.is_some() && origin == host
}
/// A `Set-Cookie` value for `name`, lasting `secs` (0 clears it), Secure
/// when the site is served over https, and out of the pages' script.
fn set_cookie(gw: &Gateway, name: &str, value: &str, secs: u64) -> HeaderValue {
cookie_for(
gw,
&format!("{name}={value}; Path=/; Max-Age={secs}; HttpOnly"),
)
}
/// [`set_cookie`], for a cookie the pages' script changes too.
fn set_page_cookie(gw: &Gateway, name: &str, value: &str, secs: u64) -> HeaderValue {
cookie_for(gw, &format!("{name}={value}; Path=/; Max-Age={secs}"))
}
/// The pages' theme for a browser now in use as `did`: the one it chose,
/// kept in a cookie of the pages' own, where their script changes it, and
/// for one of its own, its palette in another; and the same of the one it
/// chose for night, if it chose two. Having chosen none, no cookies, so
/// the system's light or dark rather than the account's before. None
/// without an index to say.
fn theme_cookies(app: &App, gw: &Gateway, did: &str) -> Vec<HeaderValue> {
let asked = app.index.as_ref().map(|i| (i.theme(did), i.night(did)));
let Some((Ok(day), Ok(night))) = asked else {
return Vec::new();
};
let night = night.filter(|_| day.is_some());
let lasts = |value: &str| [0, YEAR][usize::from(!value.is_empty())];
let mut set = Vec::new();
for ((theme, palette), (named, coloured)) in [
(drawn(day), (THEME, PALETTE)),
(drawn(night), (NIGHT, NIGHT_PALETTE)),
] {
set.push(set_page_cookie(gw, named, &theme, lasts(&theme)));
set.push(set_page_cookie(gw, coloured, &palette, lasts(&palette)));
}
set
}
/// A theme chosen, as the pages' cookies keep it: its name, over light's
/// or dark's for one of the account's own, and that one's palette; or
/// nothing, for none.
fn drawn(chosen: Option<(String, Option<serde_json::Value>)>) -> (String, String) {
let (theme, palette) = chosen.unwrap_or_default();
let palette = palette.as_ref().and_then(crate::web::palette);
let palette = palette.filter(|_| theme == "custom");
// A theme of its own is drawn over light's or dark's, as it says.
let theme = match &palette {
Some(p) => format!("custom-{}", p["colorScheme"].as_str().unwrap_or_default()),
None => theme,
};
let cookie = palette.as_ref().map(crate::web::palette_cookie);
(theme, cookie.unwrap_or_default())
}
/// `cookie`, same-site, and Secure when the site is served over https.
fn cookie_for(gw: &Gateway, cookie: &str) -> HeaderValue {
let https = gw.endpoint.starts_with("https://");
let secure = ["", "; Secure"][usize::from(https)];
HeaderValue::from_str(&format!("{cookie}; SameSite=Lax{secure}")).expect("a cookie is a header")
}
/// A page to go back to: a path of this site, else its front page.
pub fn back_to(back: Option<&str>) -> String {
match back {
Some(b) if b.starts_with('/') && !b.starts_with("//") && !b.contains('\\') => b.into(),
_ => "/".into(),
}
}
/// The pages' OAuth client metadata, which their client ID names.
pub async fn client_metadata(State(app): State<Arc<App>>) -> Result<Json<Value>, AppError> {
let gw = gateway::gateway(&app)?;
let back = format!("{}{CALLBACK}", gw.oauth_url);
let client = client(&app, gw)?;
Ok(Json(gateway::metadata(
&client,
"knotless",
&back,
&oauth::SCOPE,
)))
}
#[derive(Deserialize)]
pub struct Start {
/// Who is logging in; none when making an account.
#[serde(default)]
handle: String,
/// Making an account, rather than logging in to one.
#[serde(default)]
create: bool,
back: Option<String>,
}
/// Starts logging in as `handle`, or, with `create`, making an account on
/// the PDS this site sends people to and logging in as it: answers the
/// page where they approve it, and marks this browser as the one that
/// started it.
pub async fn login(
State(app): State<Arc<App>>,
headers: HeaderMap,
Json(start): Json<Start>,
) -> Result<Response, AppError> {
let gw = gateway::gateway(&app)?;
if !same_site(&headers) {
return Err(AppError(StatusCode::FORBIDDEN, "not from this site".into()));
}
let (url, state) = begin(&app, gw, start, None).await?;
let mut res = Json(json!({ "url": url })).into_response();
let started = set_cookie(gw, STARTED, &state, TAKES.as_secs());
res.headers_mut().append(header::SET_COOKIE, started);
Ok(res)
}
#[derive(Deserialize)]
pub struct AppStart {
#[serde(default)]
handle: String,
#[serde(default)]
create: bool,
challenge: String,
}
/// Starts the app's login, in the browser it opens for it: as [`login`],
/// but going straight on to the PDS, as nothing here can read an answer.
/// The session goes to whoever holds the verifier of `challenge`.
pub async fn app_login(
State(app): State<Arc<App>>,
Query(ask): Query<AppStart>,
) -> Result<Response, AppError> {
let gw = gateway::gateway(&app)?;
if ask.challenge.len() != CHALLENGE_LEN {
let why = "challenge is not a PKCE challenge".to_string();
return Err(AppError(StatusCode::BAD_REQUEST, why));
}
let start = Start {
handle: ask.handle,
create: ask.create,
back: None,
};
let (url, state) = begin(&app, gw, start, Some(ask.challenge)).await?;
let mut res = Redirect::to(&url).into_response();
let started = set_cookie(gw, STARTED, &state, TAKES.as_secs());
res.headers_mut().append(header::SET_COOKIE, started);
Ok(res)
}
/// Asks the PDS to approve `start`, and keeps what comes back waiting:
/// answers the page to approve it on, and the state naming it.
async fn begin(
app: &App,
gw: &Gateway,
start: Start,
challenge: Option<String>,
) -> Result<(String, String), AppError> {
let back = format!("{}{CALLBACK}", gw.oauth_url);
let client = client(app, gw)?;
let scope = oauth::SCOPE.as_str();
let request = if start.create {
oauth::sign_up(&app.ctx, &client, &gw.sign_up_pds, &back, scope).await
} else {
let handle = start.handle.trim().trim_start_matches('@').to_lowercase();
let Ok(id) = identity::resolve(&app.ctx, &handle).await else {
let why = format!("No account answers to {handle}.");
return Err(AppError(StatusCode::BAD_REQUEST, why));
};
if app.taken_down(&id.did) {
let why = format!("{handle} is taken down here.");
return Err(AppError(StatusCode::FORBIDDEN, why));
}
oauth::authorize(&app.ctx, &client, id, &handle, &back, scope).await
};
let request = request.map_err(AppError::upstream)?;
let url = request.url.clone();
let state = request.state.clone();
let mut logins = gw.logins.lock().unwrap();
logins.insert(
state.clone(),
Pending {
started: Instant::now(),
request,
back: back_to(start.back.as_deref()),
challenge,
},
);
// Logins started and never finished go once they could no longer be.
logins.retain(|_, p| p.started.elapsed() < TAKES);
Ok((url, state))
}
/// Sends a login the app started back to it: with a code for its session if
/// it was approved, which only the challenge's verifier can trade, else
/// saying it was not. The session is never in the address itself, which
/// passes through the browser.
fn back_to_the_app(gw: &Gateway, did: Option<String>, challenge: String) -> Response {
let address = match did {
Some(did) => {
let code = oauth::random(32);
let mut codes = gw.app_codes.lock().unwrap();
let made = Instant::now();
let waiting = AppCode {
made,
did,
challenge,
};
codes.insert(code.clone(), waiting);
// Codes never traded go once they could no longer be.
codes.retain(|_, c| c.made.elapsed() < APP_CODE_LASTS);
format!("{}{APP_RETURN}?code={code}", gw.endpoint)
}
None => format!("{}{APP_RETURN}?error=refused", gw.endpoint),
};
let mut res = Redirect::to(&address).into_response();
let finished = set_cookie(gw, STARTED, "", 0);
res.headers_mut().append(header::SET_COOKIE, finished);
res
}
/// What says the app may answer [`APP_RETURN`], which iOS reads to let it.
pub async fn app_association() -> Json<Value> {
Json(json!({
"applinks": {
"details": [{
"appIDs": [APP_ID],
"components": [{ "/": APP_RETURN }],
}],
},
// What a browser session asks of an app it comes back to over https.
"webcredentials": { "apps": [APP_ID] },
}))
}
/// [`APP_RETURN`] in a browser with no app to answer it, as on a computer.
/// It says nothing of the code, which is no use here.
pub async fn app_return() -> Html<&'static str> {
Html(
"<!doctype html><title>knotless</title><p>Go back to the knotless app to finish signing in.</p>",
)
}
#[derive(Deserialize)]
pub struct AppTrade {
code: String,
verifier: String,
}
/// Trades the code the app came back with, and the verifier of the challenge
/// it started with, for the session the pages' cookie would hold, which it
/// then sends as that cookie, so every query and write takes it as they
/// take a page's. A code goes at its first try, right or wrong.
pub async fn app_token(
State(app): State<Arc<App>>,
Json(trade): Json<AppTrade>,
) -> Result<Json<Value>, AppError> {
let gw = gateway::gateway(&app)?;
let waiting = gw.app_codes.lock().unwrap().remove(&trade.code);
let verified = oauth::sha256(&trade.verifier);
let waiting = waiting.filter(|c| c.made.elapsed() < APP_CODE_LASTS && c.challenge == verified);
let Some(AppCode { did, .. }) = waiting.filter(|c| !app.taken_down(&c.did)) else {
let why = "This sign-in has run out. Sign in again.".to_string();
return Err(AppError(StatusCode::BAD_REQUEST, why));
};
let token = signed(&app, &did)?;
Ok(Json(json!({ "did": did, "token": token })))
}
/// A session cookie's value for `did`, good from now for as long as a login
/// lasts, until the account logs out.
fn signed(app: &App, did: &str) -> Result<String, AppError> {
let key = oauth::client_key(&app.ctx).map_err(AppError::internal)?;
let claims = Claims {
kind: "web".into(),
did: did.to_string(),
iat: keys::now_micros(),
exp: now() + LASTS,
};
Ok(key.sign_jwt(&json!({ "typ": "JWT" }), &claims))
}
/// Where the browser comes back to from the PDS: keeps the session and
/// sets the cookie naming the account, then goes back to the page the
/// login started on. A login refused, or one this browser did not start,
/// goes back logged in as no one.
pub async fn callback(
State(app): State<Arc<App>>,
headers: HeaderMap,
Query(params): Query<HashMap<String, String>>,
) -> Result<Response, AppError> {
let gw = gateway::gateway(&app)?;
let state = params.get("state").map_or("", String::as_str);
let mine = cookie(&headers, STARTED).is_some_and(|s| s == state);
// Every account the browser is logged in to already, the one in use
// first, to stay logged in to beside the new one.
let mut before = others(&app, &headers, viewer(&app, &headers).as_deref());
if let (Some(did), Some(token)) = (viewer(&app, &headers), cookie(&headers, COOKIE)) {
before.insert(0, (did, token));
}
let pending = mine.then(|| gw.logins.lock().unwrap().remove(state));
let Some(pending) = pending.flatten() else {
return Err(AppError(
StatusCode::BAD_REQUEST,
"no login is waiting for this in this browser".into(),
));
};
let challenge = pending.challenge.clone();
let mut res = Redirect::to(&pending.back).into_response();
let headers = res.headers_mut();
headers.append(header::SET_COOKIE, set_cookie(gw, STARTED, "", 0));
let started = pending.started;
let client = client(&app, gw)?;
let kept = keep(&app, &client, pending, ¶ms).await;
let did = kept.as_ref().ok().map(String::as_str);
app.event("web.login", "", did.is_some(), started.elapsed(), did);
// Their private repositories, found as they come, for their profile.
if let Some(did) = did {
crate::spaces::discover(&app, did);
}
if let Some(challenge) = challenge {
return Ok(back_to_the_app(gw, kept.ok(), challenge));
}
if let Ok(did) = kept {
before.retain(|(other, _)| *other != did);
let token = signed(&app, &did)?;
headers.append(header::SET_COOKIE, set_cookie(gw, COOKIE, &token, LASTS));
if !before.is_empty() {
headers.append(header::SET_COOKIE, set_others(gw, &before));
}
for theme in theme_cookies(&app, gw, &did) {
headers.append(header::SET_COOKIE, theme);
}
}
Ok(res)
}
/// Trades the PDS's answer for a session, and keeps it, with the key it
/// refreshes with, in the account's directory. Returns the account.
async fn keep(
app: &App,
client: &oauth::Client,
pending: Pending,
params: &HashMap<String, String>,
) -> anyhow::Result<String> {
let session = pending.request.redeem(&app.ctx, client, params).await?;
let session = oauth::named(&app.ctx, session).await?;
// Asked again, for an account made or named only now.
anyhow::ensure!(!app.taken_down(&session.did), "taken down here");
let ctx = ctx_for(app, &session.did);
let key = oauth::client_key(&app.ctx)?;
oauth::set_client_key(&ctx, &key)?;
session::save(&ctx, &session)?;
crate::spaces::in_scope_again(&session.did);
Ok(session.did)
}
#[derive(Deserialize)]
pub struct Switch {
did: String,
}
/// Switches the browser to `did`, another account it is logged in to: its
/// cookie becomes the one in use, and the one in use joins the others.
pub async fn switch(
State(app): State<Arc<App>>,
headers: HeaderMap,
Json(to): Json<Switch>,
) -> Result<Response, AppError> {
let gw = gateway::gateway(&app)?;
if !same_site(&headers) {
return Err(AppError(StatusCode::FORBIDDEN, "not from this site".into()));
}
let current = viewer(&app, &headers);
let mut others = others(&app, &headers, current.as_deref());
let Some(at) = others.iter().position(|(did, _)| *did == to.did) else {
let why = "This browser is not logged in to that account.";
return Err(AppError(StatusCode::BAD_REQUEST, why.into()));
};
let (_, token) = others.remove(at);
if let (Some(did), Some(was)) = (current, cookie(&headers, COOKIE)) {
others.insert(0, (did, was));
}
let mut res = Json(json!({})).into_response();
let set = res.headers_mut();
set.append(header::SET_COOKIE, set_cookie(gw, COOKIE, &token, LASTS));
set.append(header::SET_COOKIE, set_others(gw, &others));
for theme in theme_cookies(&app, gw, &to.did) {
set.append(header::SET_COOKIE, theme);
}
Ok(res)
}
/// Logs the account the cookie names out: revokes its session here, and
/// refuses every cookie signed for it until it logs in again. The browser
/// goes on as the other account it used last, if it is logged in to one.
pub async fn logout(State(app): State<Arc<App>>, headers: HeaderMap) -> Result<Response, AppError> {
let gw = gateway::gateway(&app)?;
if !same_site(&headers) {
return Err(AppError(StatusCode::FORBIDDEN, "not from this site".into()));
}
if let Some(did) = viewer(&app, &headers) {
refused(&app, &did).map_err(AppError::internal)?;
// What it was shown of private repositories goes at once, with
// what read them.
crate::private::forget(&app, &did);
let ctx = ctx_for(&app, &did);
let logged_out = session::logout(&ctx, &did).await;
logged_out.map_err(AppError::upstream)?;
}
let mut res = Json(json!({})).into_response();
moved_on(&app, gw, &headers, &mut res);
Ok(res)
}
/// Refuses every cookie signed for `did` until now.
fn refused(app: &App, did: &str) -> anyhow::Result<()> {
let ctx = ctx_for(app, did);
std::fs::create_dir_all(&ctx.env.config_dir)?;
let out = ctx.env.config_dir.join(LOGGED_OUT);
std::fs::write(&out, keys::now_micros().to_string())?;
Ok(())
}
/// Refuses every cookie signed for `did`, whose session here is gone, so
/// that none signed before works once it logs in again.
pub(crate) fn refuse_cookies(app: &App, did: &str) {
let _ = refused(app, did);
}
/// Has `res` move the browser of a request, its account logged out, on to
/// the other account it used last, if it is logged in to one, or to none.
pub(crate) fn moved_on(app: &App, gw: &Gateway, headers: &HeaderMap, res: &mut Response) {
// Read once the account is logged out, so none of its cookies is left.
let mut others = others(app, headers, None);
let next = (!others.is_empty()).then(|| others.remove(0));
let set = res.headers_mut();
let token = next.as_ref().map_or("", |(_, token)| token.as_str());
let lasts = [0, LASTS][usize::from(next.is_some())];
set.append(header::SET_COOKIE, set_cookie(gw, COOKIE, token, lasts));
if cookie(headers, OTHERS).is_some() {
set.append(header::SET_COOKIE, set_others(gw, &others));
}
let themes = next.map(|(did, _)| theme_cookies(app, gw, &did));
for theme in themes.unwrap_or_default() {
set.append(header::SET_COOKIE, theme);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
let mut h = HeaderMap::new();
for (k, v) in pairs {
h.append(
axum::http::HeaderName::from_bytes(k.as_bytes()).unwrap(),
HeaderValue::from_str(v).unwrap(),
);
}
h
}
#[test]
fn cookies_are_read_by_name() {
let h = headers(&[("cookie", "a=1; knotless_session=t"), ("cookie", "b=2")]);
assert_eq!(cookie(&h, COOKIE).as_deref(), Some("t"));
assert_eq!(cookie(&h, "b").as_deref(), Some("2"));
assert_eq!(cookie(&h, "c"), None);
}
#[test]
fn only_this_site_posts() {
assert!(same_site(&headers(&[("host", "knotless.dev")])));
let ours = [("host", "knotless.dev"), ("origin", "https://knotless.dev")];
assert!(same_site(&headers(&ours)));
let theirs = [("host", "knotless.dev"), ("origin", "https://evil.example")];
assert!(!same_site(&headers(&theirs)));
let forwarded = [
("host", "127.0.0.1:2590"),
("x-forwarded-host", "knotless.dev"),
("origin", "https://knotless.dev"),
];
assert!(same_site(&headers(&forwarded)));
assert!(!same_site(&headers(&[("origin", "null")])));
}
#[test]
fn going_back_stays_on_the_site() {
assert_eq!(
back_to(Some("/alice.test/proj?tab=x")),
"/alice.test/proj?tab=x"
);
for away in ["https://evil.example", "//evil.example", "/\\evil.example"] {
assert_eq!(back_to(Some(away)), "/");
}
assert_eq!(back_to(None), "/");
}
}