chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
The iOS app can log in through a browser and hold a page's 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, ¶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 { | ||
// 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 | ||