//! 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, } /// 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 { 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 { 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 { 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 { 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::>().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 { 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 { 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)>) -> (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>) -> Result, 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, } /// 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>, headers: HeaderMap, Json(start): Json, ) -> Result { 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>, Query(ask): Query, ) -> Result { 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, ) -> 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, 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 { 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( "knotless

Go back to the knotless app to finish signing in.

", ) } #[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>, Json(trade): Json, ) -> Result, 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 { 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>, headers: HeaderMap, Query(params): Query>, ) -> Result { 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, ) -> anyhow::Result { 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>, headers: HeaderMap, Json(to): Json, ) -> Result { 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>, headers: HeaderMap) -> Result { 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), "/"); } }