~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

The iOS app can log in through a browser and hold a page's session

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:feature/ios-session

Only what this pull adds. An iOS app reads the inbox, pulls, diffs, repositories and profiles landed as e0c925b, so its changes do not show here again.

crates/knotless-serve/src/gateway.rs+3−0
@@ -75,6 +75,8 @@ pub struct Gateway {
bot_logins: Mutex<HashMap<String, (Instant, oauth::Request)>>,
/// Logins to the pages waiting for approval, by state.
pub(crate) logins: Mutex<HashMap<String, crate::login::Pending>>,
/// Logins the app finished, waiting for it to trade their code.
pub(crate) app_codes: Mutex<HashMap<String, crate::login::AppCode>>,
/// The PDS where the pages send someone making an account: one that
/// takes anyone, [`SIGN_UP_PDS`] unless `--sign-up-pds` names another.
pub sign_up_pds: String,
@@ -143,6 +145,7 @@ impl Gateway {
bots: None,
bot_logins: Mutex::default(),
logins: Mutex::default(),
app_codes: Mutex::default(),
sign_up_pds: SIGN_UP_PDS.into(),
})
}
crates/knotless-serve/src/lib.rs+7−0
@@ -1165,6 +1165,13 @@ pub fn router(app: Arc<App>) -> Router {
.route("/oauth/bot-callback", get(gateway::bot_callback))
.route(login::METADATA, get(login::client_metadata))
.route("/oauth/login", post(login::login))
.route("/oauth/app-login", get(login::app_login))
.route("/oauth/app-token", post(login::app_token))
.route(login::APP_RETURN, get(login::app_return))
.route(
"/.well-known/apple-app-site-association",
get(login::app_association),
)
.route("/oauth/web-callback", get(login::callback))
.route("/oauth/switch", post(login::switch))
.route("/oauth/logout", post(login::logout))
crates/knotless-serve/src/login.rs+169−19
@@ -29,7 +29,7 @@ use crate::{App, AppError};
use axum::Json;
use axum::extract::{Query, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Redirect, Response};
use axum::response::{Html, IntoResponse, Redirect, Response};
use knotless::crypto;
use knotless::env::Ctx;
use knotless::session;
@@ -67,6 +67,15 @@ const STARTED: &str = "knotless_login";
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";
@@ -77,6 +86,16 @@ pub struct Pending {
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.
@@ -343,8 +362,56 @@ pub async fn login(
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 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
@@ -370,15 +437,99 @@ pub async fn login(
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);
drop(logins);
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)
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 }],
}],
},
}))
}
/// [`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
@@ -406,6 +557,7 @@ pub async fn callback(
"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));
@@ -414,23 +566,21 @@ pub async fn callback(
let kept = keep(&app, &client, pending, &params).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 {
// Their private repositories, found as they come, for their profile.
crate::spaces::discover(&app, &did);
let key = oauth::client_key(&app.ctx).map_err(AppError::internal)?;
let claims = Claims {
kind: "web".into(),
did,
iat: keys::now_micros(),
exp: now() + LASTS,
};
before.retain(|(other, _)| *other != claims.did);
let token = key.sign_jwt(&json!({ "typ": "JWT" }), &claims);
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, &claims.did) {
for theme in theme_cookies(&app, gw, &did) {
headers.append(header::SET_COOKIE, theme);
}
}
crates/knotless-serve/tests/pages.rs+94−0
@@ -287,6 +287,100 @@ fn making_an_account_to_log_in() {
assert_eq!(log_in_as(&server, start, &who, ""), None);
}
/// The app's login, through the browser it opens, as `who`: where it
/// came back to.
fn app_log_in(server: &Server, who: &Account, challenge: &str, extra: &str) -> String {
let client = reqwest::blocking::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
let url = format!("{}/oauth/app-login", server.base);
let ask = [("handle", who.handle.as_str()), ("challenge", challenge)];
let res = client.get(&url).query(&ask).send().unwrap();
assert_eq!(res.status().as_u16(), 303);
let headers = res.headers();
let started = headers["set-cookie"].to_str().unwrap();
let started = started.split(';').next().unwrap();
let pds = headers["location"].to_str().unwrap();
let command = format!("{} --cookie {started} {pds}", browser(who, extra));
let mut words = command.split_whitespace();
let out = std::process::Command::new(words.next().unwrap())
.args(words)
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let out = String::from_utf8(out.stdout).unwrap();
let back = out.lines().find_map(|l| l.strip_prefix("app: "));
back.unwrap().to_string()
}
/// The app logs in through a browser, comes back with a code only its
/// verifier can trade, once, and with what it gets asks and writes as a
/// page's cookie does. A login refused gives it nothing, and nor does an
/// account taken down since.
#[test]
fn the_app_logs_in_with_a_code_only_it_can_trade() {
let server = server();
let who = person(account());
let verifier = "the-app-keeps-this-to-itself-and-sends-it-once";
let challenge = knotless::oauth::sha256(verifier);
let trade = format!("{}/oauth/app-token", server.base);
let code_from = |back: &str| back.split_once("?code=").unwrap().1.to_string();
let swap = |code: &str, verifier: &str| {
let ask = json!({"code": code, "verifier": verifier});
browse("POST", &trade, None, Some(ask))
};
let start = format!("{}/oauth/app-login?handle=a&challenge=short", server.base);
assert_eq!(browse("GET", &start, None, None).0, 400);
let refused = app_log_in(&server, &who.account, &challenge, "--reject");
let where_to = knotless_serve::login::APP_RETURN;
assert_eq!(refused, format!("{}{where_to}?error=refused", server.base));
// A browser with no app to take over is told so, and iOS can see it may.
let (status, _, _) = browse("GET", &format!("{}{where_to}", server.base), None, None);
assert_eq!(status, 200);
let named = format!("{}/.well-known/apple-app-site-association", server.base);
let (status, _, said) = browse("GET", &named, None, None);
assert_eq!(status, 200);
let details = &said["applinks"]["details"][0];
assert_eq!(details["appIDs"][0], json!("YN68LN9T7Z.dev.knotless.app"));
assert_eq!(details["components"][0]["/"], json!(where_to));
// A wrong verifier spends the code, for whoever stole it.
let code = code_from(&app_log_in(&server, &who.account, &challenge, ""));
assert_eq!(swap(&code, "not-the-verifier").0, 400);
assert_eq!(swap(&code, verifier).0, 400);
let code = code_from(&app_log_in(&server, &who.account, &challenge, ""));
let (status, set, answer) = swap(&code, verifier);
assert_eq!(status, 200, "{answer}");
assert_eq!(set, Vec::<String>::new());
assert_eq!(answer["did"], json!(who.account.did));
assert_eq!(swap(&code, verifier).0, 400);
let cookie = format!("knotless_session={}", answer["token"].as_str().unwrap());
let me = Some(cookie.as_str());
let (status, _, shown) = browse("GET", &server.xrpc("getViewer"), me, None);
assert_eq!(status, 200, "{shown}");
assert_eq!(shown["actor"]["did"], json!(who.account.did));
let (status, made) = server.call("createRepo", me, json!({"name": "from-the-app"}));
assert_eq!(status, 200, "{made}");
let code = code_from(&app_log_in(&server, &who.account, &challenge, ""));
let index = server.app.index.clone().unwrap();
let subject = json!({"$type": "com.atproto.admin.defs#repoRef", "did": who.account.did});
let down = |applied| index.take_down(&server.did, &who.account.did, &subject, applied);
down(true).unwrap();
assert_eq!(swap(&code, verifier).0, 400);
down(false).unwrap();
}
/// Someone logged in to the pages makes a repository there, and forks
/// another, as `knotless repo create` and `knotless repo fork` do; and only someone
/// logged in can.
docs/design/security.md+3−1
@@ -130,7 +130,9 @@ PDS as `knotless account delegate` has you do. The browser holds only a signed,
HttpOnly cookie naming the account. A write from the pages takes that
cookie, never a service token, even one naming the same account; it and
logging out must come from the site's own origin, and a login finishes only in
the browser that started it. Logging out revokes the session; revoking the
the browser that started it. The iOS app holds the same signed value in its
keychain, traded for a one-time code and its PKCE verifier
([serve.md](serve.md#logging-in)). Logging out revokes the session; revoking the
grant from your PDS does the same. An account taken down on the server is
logged out of its pages until it is restored, as is one its own host
deactivates, takes down or suspends, until the host says it is active
docs/design/serve.md+22−0
@@ -805,6 +805,28 @@ labeler the server follows, cannot log in, and its cookie names no one, so
it reads as no one and writes nothing. Restored, it is logged in again
wherever the cookie still holds.
The iOS app logs in the same way, so that what it asks and writes is what a
page would, with nothing new for the server to check. It opens a browser at
`/oauth/app-login` with a PKCE challenge, and the account approves as on the
pages. The callback then sets no cookie: it sends the browser on to
`/oauth/app-return` with a one-time code, and the app trades the code and its
verifier at `/oauth/app-token` for the value a session cookie would hold, which
it keeps in its keychain and sends as that cookie. It is not in the address
because that passes through the browser: a code alone is no use.
That address is a universal link, which iOS gives only to the app that
`/.well-known/apple-app-site-association` names. A custom URL scheme would not
do, as any app can claim one: it could start this login with a challenge of its
own, and the person would approve on knotless's own page and hand it their
session. What it does not stop is an app showing the login in a web view of its
own, which reads every address it loads, code included. Such an app can read a
password typed there just the same, which is why a login belongs in the system's
browser, as the knotless app's is.
A login an app started is still finished only in the browser that started it.
It shares the account's session on the server, so logging out on either logs out
both, and sending no `Origin`, the app is no cross-site request to refuse.
Someone with no account makes one on the way. The login starts as a
sign-up instead: no handle, and the request goes to the PDS the server
sends people to (`--sign-up-pds`, selfhosted.social unless set) with
docs/reference/api.md+4−0
@@ -634,6 +634,10 @@ from this site` unless its `Origin` is the site's own.
| --- | --- |
| `GET /oauth/web-client-metadata.json` | the pages' OAuth client metadata |
| `POST /oauth/login` | `{handle, back}`: `{url}`, the PDS's page to approve on, and a `knotless_login` cookie for 15 minutes; 400 `No account answers to <handle>.`, 403 `<handle> is taken down here.` Or `{create: true, back}`: the same, the page being the sign-up page of the PDS `--sign-up-pds` names, and the account made there logged in as it comes back |
| `GET /oauth/app-login?handle=&create=&challenge=` | the iOS app's login, in the browser it opens: as `POST /oauth/login`, with `challenge` a PKCE S256 challenge, but redirecting to the PDS's page rather than answering it; 400 for another length of challenge. Approved, the callback goes on to `/oauth/app-return?code=<code>`, setting no session cookie; refused, to `?error=refused` |
| `GET /oauth/app-return` | a universal link the app answers; in a browser with no app, a page saying to go back to it |
| `GET /.well-known/apple-app-site-association` | names the iOS app as the one that may answer `/oauth/app-return` |
| `POST /oauth/app-token` | `{code, verifier}`: `{did, token}`, `token` the value of a `knotless_session` cookie, which the app sends as that cookie; 400 for a code unknown, spent, over five minutes old or of an account taken down, or a verifier that isn't the challenge's, which spends the code |
| `GET /oauth/web-callback` | where the PDS sends the browser back: on to `back` (a path on the site, else `/`), setting `knotless_session` for 90 days if approved, and keeping the account it replaces, if any, in `knotless_accounts`; 400 for a login this browser did not start |
| `POST /oauth/switch` | `{did}`: makes that account, one `knotless_accounts` holds, the one in use, keeping the one before in its place; 400 `This browser is not logged in to that account.` |
| `GET /oauth/site` | `?host=<private site's host>&to=<path>`, with `--pages-domain`: to one logged in and a member of the repository, a redirect to `/.knotless/enter` on that host with a ticket, signed, for that host alone, that lasts a minute and is taken once; to one not logged in, a redirect to `/?login=`, where the pages open their login and come back here; 404 for a host that names no private site, or one who is not a member |
harness/authorize.mjs+5−0
@@ -148,6 +148,11 @@ if (!location) throw new Error(`redirect: ${back.status} ${back.body}`)
let here = done.url
for (let hops = 0; location && hops < 5; hops++) {
here = new URL(location, here).href
// Where an app takes over from the browser: said, for a test.
if (here.includes('/oauth/app-return')) {
console.log(`app: ${here}`)
break
}
const res = await send(here, { headers: theirs ? { cookie: theirs } : {} })
if (theirs) for (const cookie of res.headers['set-cookie'] ?? []) console.log(cookie)
location = res.headers.location