Device sign-in: approve an app's code on the web, the app gets a scoped token (closes #67) df71c7892d27
- POST /api/v1/device/code and /api/v1/device/token, in the shape of RFC 8628: a device code (stored hashed) to poll with, a user code to show, authorization_pending / slow_down / access_denied / expired_token while it waits. - /device, signed in: enter the code, see the app's name and the scopes it asked for, approve with any of them, or deny. The token is made by the next poll, once, named 'device: CLIENT', so none is stored in the clear; approvals, denials and device tokens are audited. - oh auth login now signs in this way: it shows the link and code and waits; --token TOKEN (or - for stdin) still takes a token. Spec section 5.6 and docs/oh.md describe it.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-01 00:43 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
df71c7892d27971a649ed520779dbe26ed0d80dab8352a81a38a3945be617c2c- Depends on
- 145306044896 19bb31ff1f5c 38833ed78707 5c6e92fd8d71 93be9ce4653e b64e2537d702 d23464c0ac60 e9bfea8e26db ef20fdc14e8b
- Depended on by
- a22e4aec2e28 1c52d940b4a0 fac6853c4e46 ecac6e8d9b41 166f6e9bf4f1 8aa5ef384ef1 c8684d44d549 ea14c4ec5c3f 5d90e0e045e2 d74ce22fa80e aa0bfbd11f02 2e9188ca4f98 6246d1e51f4f 757195ad7a5d 92a797b20912 554ccacfc123 62ceb2eec18c 83940d53664f 4da8b828a822 60f11b71a740 b66e70b7b6c7
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 24 atoms, 4 tree operations, 12 files
Browse the tree at this change
added crates/hub/migrations/0027_device_codes.sql +17 −0
| 1 | + | -- Device sign-in (oxygenhub#67, RFC 8628 in shape): an app holds the |
|
| 2 | + | -- device code (stored as its hash) and polls; the user approves the user |
|
| 3 | + | -- code on the web, choosing the scopes; the token is made when the app |
|
| 4 | + | -- next polls, so no token is stored in the clear. |
|
| 5 | + | CREATE TABLE device_codes ( |
|
| 6 | + | id INTEGER PRIMARY KEY, |
|
| 7 | + | device_hash TEXT NOT NULL UNIQUE, |
|
| 8 | + | user_code TEXT NOT NULL UNIQUE, |
|
| 9 | + | client TEXT NOT NULL, |
|
| 10 | + | scopes TEXT NOT NULL, |
|
| 11 | + | created_ms INTEGER NOT NULL, |
|
| 12 | + | expires_ms INTEGER NOT NULL, |
|
| 13 | + | last_poll_ms INTEGER NOT NULL DEFAULT 0, |
|
| 14 | + | account_id INTEGER REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 15 | + | approved_scopes TEXT NOT NULL DEFAULT '', |
|
| 16 | + | denied INTEGER NOT NULL DEFAULT 0 |
|
| 17 | + | ); |
added crates/hub/src/api/device.rs +148 −0
| 1 | + | //! Device sign-in (oxygenhub#67), in the shape of RFC 8628: an app that |
|
| 2 | + | //! cannot take a password asks for a code, the user approves it on the web |
|
| 3 | + | //! (signed in, with their second factor), and the app's next poll gets a |
|
| 4 | + | //! token with the scopes the user allowed. |
|
| 5 | + | ||
| 6 | + | use crate::db::{self, accounts}; |
|
| 7 | + | use crate::Shared; |
|
| 8 | + | use axum::extract::State; |
|
| 9 | + | use axum::http::StatusCode; |
|
| 10 | + | use axum::response::{IntoResponse, Response}; |
|
| 11 | + | use axum::Json; |
|
| 12 | + | use serde::Deserialize; |
|
| 13 | + | use serde_json::json; |
|
| 14 | + | ||
| 15 | + | /// How long a code lasts, and how often an app may poll. |
|
| 16 | + | const LIFETIME_MS: i64 = 15 * 60 * 1000; |
|
| 17 | + | const INTERVAL_S: i64 = 5; |
|
| 18 | + | /// User codes: consonants only, so no word is spelt and none is mistaken |
|
| 19 | + | /// for another letter or a digit. |
|
| 20 | + | const USER_ALPHABET: &[u8] = b"BCDFGHJKLMNPQRSTVWXZ"; |
|
| 21 | + | ||
| 22 | + | #[derive(Deserialize)] |
|
| 23 | + | pub struct CodeRequest { |
|
| 24 | + | /// What the app calls itself, shown when the user approves it. |
|
| 25 | + | #[serde(default)] |
|
| 26 | + | client: String, |
|
| 27 | + | #[serde(default)] |
|
| 28 | + | scopes: Vec<String>, |
|
| 29 | + | } |
|
| 30 | + | ||
| 31 | + | /// A user code, `XXXX-XXXX`. |
|
| 32 | + | fn user_code() -> String { |
|
| 33 | + | use rand::RngCore; |
|
| 34 | + | let mut b = [0u8; 8]; |
|
| 35 | + | rand::rngs::OsRng.fill_bytes(&mut b); |
|
| 36 | + | let c: String = b.iter().map(|x| USER_ALPHABET[*x as usize % USER_ALPHABET.len()] as char).collect(); |
|
| 37 | + | format!("{}-{}", &c[..4], &c[4..]) |
|
| 38 | + | } |
|
| 39 | + | ||
| 40 | + | /// A user code as typed: upper case, spaces and dashes ignored. |
|
| 41 | + | pub fn normalize_user_code(s: &str) -> String { |
|
| 42 | + | let c: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).map(|c| c.to_ascii_uppercase()).collect(); |
|
| 43 | + | if c.len() == 8 { |
|
| 44 | + | format!("{}-{}", &c[..4], &c[4..]) |
|
| 45 | + | } else { |
|
| 46 | + | c |
|
| 47 | + | } |
|
| 48 | + | } |
|
| 49 | + | ||
| 50 | + | fn oauth_error(status: StatusCode, error: &str, detail: &str) -> Response { |
|
| 51 | + | (status, Json(json!({ "error": error, "error_description": detail }))).into_response() |
|
| 52 | + | } |
|
| 53 | + | ||
| 54 | + | /// `POST /api/v1/device/code`: a device code to poll with and a user code |
|
| 55 | + | /// to show. No token needed. |
|
| 56 | + | pub async fn code(State(app): State<Shared>, Json(req): Json<CodeRequest>) -> Response { |
|
| 57 | + | let known = |s: &str| crate::web::settings::SCOPES.iter().any(|(k, _)| *k == s); |
|
| 58 | + | let mut scopes: Vec<&str> = req.scopes.iter().map(String::as_str).filter(|s| known(s)).collect(); |
|
| 59 | + | if scopes.len() != req.scopes.len() { |
|
| 60 | + | return oauth_error(StatusCode::BAD_REQUEST, "invalid_scope", "unknown scope"); |
|
| 61 | + | } |
|
| 62 | + | if scopes.is_empty() { |
|
| 63 | + | scopes = vec!["repo:read", "user:read"]; |
|
| 64 | + | } |
|
| 65 | + | let client: String = req.client.trim().chars().filter(|c| !c.is_control()).take(80).collect(); |
|
| 66 | + | let client = if client.is_empty() { "an app".to_string() } else { client }; |
|
| 67 | + | let device = format!("oxydev_{}", crate::auth::random_hex(32)); |
|
| 68 | + | let hash = crate::auth::token_hash(&device); |
|
| 69 | + | let expires = db::now_ms() + LIFETIME_MS; |
|
| 70 | + | // A clash of user codes is unlikely; try a few. |
|
| 71 | + | for _ in 0..4 { |
|
| 72 | + | let user = user_code(); |
|
| 73 | + | match accounts::create_device_code(&app.db, &hash, &user, &client, &scopes.join(","), expires).await { |
|
| 74 | + | Ok(_) => { |
|
| 75 | + | let base = app.config.base_url().trim_end_matches('/').to_string(); |
|
| 76 | + | return Json(json!({ |
|
| 77 | + | "device_code": device, |
|
| 78 | + | "user_code": user, |
|
| 79 | + | "verification_uri": format!("{base}/device"), |
|
| 80 | + | "verification_uri_complete": format!("{base}/device?code={user}"), |
|
| 81 | + | "expires_in": LIFETIME_MS / 1000, |
|
| 82 | + | "interval": INTERVAL_S, |
|
| 83 | + | })) |
|
| 84 | + | .into_response(); |
|
| 85 | + | } |
|
| 86 | + | Err(sqlx::Error::Database(e)) if e.is_unique_violation() => continue, |
|
| 87 | + | Err(e) => return oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()), |
|
| 88 | + | } |
|
| 89 | + | } |
|
| 90 | + | oauth_error(StatusCode::SERVICE_UNAVAILABLE, "server_error", "no free user code; try again") |
|
| 91 | + | } |
|
| 92 | + | ||
| 93 | + | #[derive(Deserialize)] |
|
| 94 | + | pub struct TokenRequest { |
|
| 95 | + | device_code: String, |
|
| 96 | + | } |
|
| 97 | + | ||
| 98 | + | /// `POST /api/v1/device/token`: the token once the user approved, or why |
|
| 99 | + | /// not yet (`authorization_pending`, `slow_down`, `access_denied`, |
|
| 100 | + | /// `expired_token`), as RFC 8628 has it. |
|
| 101 | + | pub async fn token(State(app): State<Shared>, Json(req): Json<TokenRequest>) -> Response { |
|
| 102 | + | let hash = crate::auth::token_hash(req.device_code.trim()); |
|
| 103 | + | let d = match accounts::device_by_hash(&app.db, &hash).await { |
|
| 104 | + | Ok(Some(d)) => d, |
|
| 105 | + | Ok(None) => return oauth_error(StatusCode::BAD_REQUEST, "expired_token", "no such device code, or it was used"), |
|
| 106 | + | Err(e) => return oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()), |
|
| 107 | + | }; |
|
| 108 | + | let now = db::now_ms(); |
|
| 109 | + | if d.expires_ms < now { |
|
| 110 | + | let _ = accounts::delete_device(&app.db, d.id).await; |
|
| 111 | + | return oauth_error(StatusCode::BAD_REQUEST, "expired_token", "the code expired; ask for a new one"); |
|
| 112 | + | } |
|
| 113 | + | if d.denied { |
|
| 114 | + | let _ = accounts::delete_device(&app.db, d.id).await; |
|
| 115 | + | return oauth_error(StatusCode::BAD_REQUEST, "access_denied", "the user denied the request"); |
|
| 116 | + | } |
|
| 117 | + | let Some(account_id) = d.account_id else { |
|
| 118 | + | let _ = accounts::device_polled(&app.db, d.id).await; |
|
| 119 | + | if now - d.last_poll_ms < INTERVAL_S * 1000 { |
|
| 120 | + | return oauth_error(StatusCode::BAD_REQUEST, "slow_down", "poll no more often than the interval"); |
|
| 121 | + | } |
|
| 122 | + | return oauth_error(StatusCode::BAD_REQUEST, "authorization_pending", "not yet approved"); |
|
| 123 | + | }; |
|
| 124 | + | // One poll takes the approval: the token is made only by the poll |
|
| 125 | + | // that removed the code. |
|
| 126 | + | match accounts::delete_device(&app.db, d.id).await { |
|
| 127 | + | Ok(true) => {} |
|
| 128 | + | Ok(false) => return oauth_error(StatusCode::BAD_REQUEST, "expired_token", "the code was used"), |
|
| 129 | + | Err(e) => return oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()), |
|
| 130 | + | } |
|
| 131 | + | let account = match accounts::account_by_id(&app.db, account_id).await { |
|
| 132 | + | Ok(Some(a)) => a, |
|
| 133 | + | _ => return oauth_error(StatusCode::BAD_REQUEST, "access_denied", "the approving account is gone"), |
|
| 134 | + | }; |
|
| 135 | + | let (secret, token_hash) = crate::auth::new_token(); |
|
| 136 | + | let name = format!("device: {}", d.client); |
|
| 137 | + | if let Err(e) = accounts::create_token(&app.db, account_id, &name, &token_hash, &d.approved_scopes, "", None).await { |
|
| 138 | + | return oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()); |
|
| 139 | + | } |
|
| 140 | + | accounts::audit(&app.db, Some(&account), "token.device", &name, &d.approved_scopes).await.ok(); |
|
| 141 | + | Json(json!({ |
|
| 142 | + | "access_token": secret, |
|
| 143 | + | "token_type": "bearer", |
|
| 144 | + | "scope": d.approved_scopes.replace(',', " "), |
|
| 145 | + | "username": account.username, |
|
| 146 | + | })) |
|
| 147 | + | .into_response() |
|
| 148 | + | } |
added crates/hub/src/web/device.rs +111 −0
| 1 | + | //! `/device`: approving an app's sign-in code (oxygenhub#67). The app shows |
|
| 2 | + | //! a code; the user, signed in here, enters it, sees what the app asked |
|
| 3 | + | //! for, and approves it with the scopes they choose, or denies it. |
|
| 4 | + | ||
| 5 | + | use super::settings::{csrf, require, SCOPES}; |
|
| 6 | + | use super::{Error, Page, Shell}; |
|
| 7 | + | use crate::api::device::normalize_user_code; |
|
| 8 | + | use crate::auth::Viewer; |
|
| 9 | + | use crate::db::{self, accounts}; |
|
| 10 | + | use crate::Shared; |
|
| 11 | + | use askama::Template; |
|
| 12 | + | use axum::extract::{Query, State}; |
|
| 13 | + | use serde::Deserialize; |
|
| 14 | + | ||
| 15 | + | #[derive(Template)] |
|
| 16 | + | #[template(path = "device.html")] |
|
| 17 | + | pub struct DevicePage { |
|
| 18 | + | shell: Shell, |
|
| 19 | + | code: String, |
|
| 20 | + | /// The pending request the code names, with each scope it asked for. |
|
| 21 | + | pending: Option<(accounts::DeviceCode, Vec<(&'static str, &'static str)>)>, |
|
| 22 | + | done: Option<String>, |
|
| 23 | + | error: Option<String>, |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | /// A code still waiting for an answer. |
|
| 27 | + | async fn pending(app: &Shared, code: &str) -> Result<Option<accounts::DeviceCode>, sqlx::Error> { |
|
| 28 | + | Ok(accounts::device_by_user_code(&app.db, code).await?.filter(|d| d.account_id.is_none() && !d.denied && d.expires_ms >= db::now_ms())) |
|
| 29 | + | } |
|
| 30 | + | ||
| 31 | + | fn page(shell: Shell, code: String, d: Option<accounts::DeviceCode>, done: Option<String>, error: Option<String>) -> Page<DevicePage> { |
|
| 32 | + | let pending = d.map(|d| { |
|
| 33 | + | let asked: Vec<(&'static str, &'static str)> = SCOPES.iter().filter(|(k, _)| d.scope_list().contains(k)).copied().collect(); |
|
| 34 | + | (d, asked) |
|
| 35 | + | }); |
|
| 36 | + | Page(DevicePage { shell, code, pending, done, error }) |
|
| 37 | + | } |
|
| 38 | + | ||
| 39 | + | #[derive(Deserialize)] |
|
| 40 | + | pub struct CodeQuery { |
|
| 41 | + | #[serde(default)] |
|
| 42 | + | code: String, |
|
| 43 | + | } |
|
| 44 | + | ||
| 45 | + | pub async fn form(State(app): State<Shared>, viewer: Viewer, Query(q): Query<CodeQuery>) -> Result<Page<DevicePage>, Error> { |
|
| 46 | + | let shell = Shell::new(&app, "/device", &viewer); |
|
| 47 | + | require(&viewer, &shell)?; |
|
| 48 | + | let code = normalize_user_code(&q.code); |
|
| 49 | + | if code.is_empty() { |
|
| 50 | + | return Ok(page(shell, code, None, None, None)); |
|
| 51 | + | } |
|
| 52 | + | let d = pending(&app, &code).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 53 | + | let error = d.is_none().then(|| "No app is waiting for that code: it may have expired. Ask the app for a new one.".to_string()); |
|
| 54 | + | Ok(page(shell, code, d, None, error)) |
|
| 55 | + | } |
|
| 56 | + | ||
| 57 | + | #[derive(Deserialize)] |
|
| 58 | + | pub struct DeviceForm { |
|
| 59 | + | #[serde(rename = "_csrf")] |
|
| 60 | + | csrf_token: String, |
|
| 61 | + | code: String, |
|
| 62 | + | #[serde(default)] |
|
| 63 | + | action: String, |
|
| 64 | + | #[serde(default)] |
|
| 65 | + | scopes: Vec<String>, |
|
| 66 | + | } |
|
| 67 | + | ||
| 68 | + | pub async fn answer( |
|
| 69 | + | State(app): State<Shared>, |
|
| 70 | + | viewer: Viewer, |
|
| 71 | + | axum_extra::extract::Form(f): axum_extra::extract::Form<DeviceForm>, |
|
| 72 | + | ) -> Result<Page<DevicePage>, Error> { |
|
| 73 | + | let shell = Shell::new(&app, "/device", &viewer); |
|
| 74 | + | let me = require(&viewer, &shell)?; |
|
| 75 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 76 | + | let code = normalize_user_code(&f.code); |
|
| 77 | + | let d = pending(&app, &code).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 78 | + | let Some(d) = d else { |
|
| 79 | + | let e = "No app is waiting for that code: it may have expired. Ask the app for a new one."; |
|
| 80 | + | return Ok(page(shell, code, None, None, Some(e.into()))); |
|
| 81 | + | }; |
|
| 82 | + | match f.action.as_str() { |
|
| 83 | + | "approve" => { |
|
| 84 | + | // Only scopes the app asked for, and at least one. |
|
| 85 | + | let asked = d.scope_list(); |
|
| 86 | + | let chosen: Vec<&str> = f.scopes.iter().map(String::as_str).filter(|s| asked.contains(s)).collect(); |
|
| 87 | + | if chosen.is_empty() { |
|
| 88 | + | return Ok(page(shell, code, Some(d), None, Some("Allow at least one of what it asks for, or deny it.".into()))); |
|
| 89 | + | } |
|
| 90 | + | let ok = accounts::approve_device(&app.db, d.id, me.account.id, &chosen.join(",")) |
|
| 91 | + | .await |
|
| 92 | + | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 93 | + | if !ok { |
|
| 94 | + | return Ok(page(shell, code, None, None, Some("That code was answered or expired meanwhile.".into()))); |
|
| 95 | + | } |
|
| 96 | + | accounts::audit(&app.db, Some(&me.account), "device.approved", &d.client, &chosen.join(",")).await.ok(); |
|
| 97 | + | let done = format!( |
|
| 98 | + | "Approved: {} is signed in as {} and will get its token in a moment. It is listed under Settings → Tokens, where you can revoke it.", |
|
| 99 | + | d.client, me.account.username |
|
| 100 | + | ); |
|
| 101 | + | Ok(page(shell, String::new(), None, Some(done), None)) |
|
| 102 | + | } |
|
| 103 | + | "deny" => { |
|
| 104 | + | accounts::deny_device(&app.db, d.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 105 | + | accounts::audit(&app.db, Some(&me.account), "device.denied", &d.client, "").await.ok(); |
|
| 106 | + | Ok(page(shell, String::new(), None, Some(format!("Denied: {} was not signed in.", d.client)), None)) |
|
| 107 | + | } |
|
| 108 | + | // Looking a typed code up. |
|
| 109 | + | _ => Ok(page(shell, code, Some(d), None, None)), |
|
| 110 | + | } |
|
| 111 | + | } |
added templates/device.html +28 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}Sign in a device{% endblock %} |
|
| 3 | + | {% block content %} |
|
| 4 | + | <div class="head"><h1>Sign in a device <small>an app asked to use your account</small></h1></div> |
|
| 5 | + | {% if let Some(d) = done %}<p class="note">{{ d }}</p>{% endif %} |
|
| 6 | + | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 7 | + | {% if let Some((d, asked)) = pending %} |
|
| 8 | + | <form class="card wide" method="post" action="/device"> |
|
| 9 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 10 | + | <input type="hidden" name="code" value="{{ d.user_code }}"> |
|
| 11 | + | <h2><strong>{{ d.client }}</strong> wants to sign in as you</h2> |
|
| 12 | + | <p class="small">Code <code>{{ d.user_code }}</code>. Approve only if this is the code your app shows. It gets a token with what you allow here; you can revoke it under Settings → Tokens.</p> |
|
| 13 | + | <label>Allow it to</label> |
|
| 14 | + | <div class="scopes"> |
|
| 15 | + | {% for (name, what) in asked %}<label><input type="checkbox" name="scopes" value="{{ name }}" checked> <code>{{ name }}</code> <span class="small mute">{{ what }}</span></label>{% endfor %} |
|
| 16 | + | </div> |
|
| 17 | + | <button type="submit" name="action" value="approve">APPROVE</button> |
|
| 18 | + | <button type="submit" name="action" value="deny" class="secondary">DENY</button> |
|
| 19 | + | </form> |
|
| 20 | + | {% else %} |
|
| 21 | + | <form class="card" method="post" action="/device"> |
|
| 22 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 23 | + | <label for="code">The code your app shows</label> |
|
| 24 | + | <input type="text" id="code" name="code" value="{{ code }}" required autofocus autocomplete="off" placeholder="BCDF-GHJK" style="text-transform:uppercase"> |
|
| 25 | + | <button type="submit" name="action" value="lookup">CONTINUE</button> |
|
| 26 | + | </form> |
|
| 27 | + | {% endif %} |
|
| 28 | + | {% endblock %} |
modified crates/hub/src/api/mod.rs +3 −0
| … | 14 unchanged lines |
||
| 15 | 15 | use hub_api::*; |
|
| 16 | 16 | use serde::Deserialize; |
|
| 17 | 17 | ||
| 18 | + | pub mod device; |
|
| 18 | 19 | mod projects; |
|
| 19 | 20 | ||
| 20 | 21 | /// Any other path under `/api/v1`: a problem document, not the site's page. |
|
| … | 22 unchanged lines |
||
| 43 | 44 | .route("/projects/{owner}/{number}", get(projects::board).patch(projects::edit)) |
|
| 44 | 45 | .route("/projects/{owner}/{number}/cards", post(projects::add_card)) |
|
| 45 | 46 | .route("/projects/{owner}/{number}/cards/{id}", axum::routing::patch(projects::move_card).delete(projects::remove_card)) |
|
| 47 | + | .route("/device/code", post(device::code)) |
|
| 48 | + | .route("/device/token", post(device::token)) |
|
| 46 | 49 | .route("/user", get(me)) |
|
| 47 | 50 | .route("/version", get(version)) |
|
| 48 | 51 | .route("/notifications", get(notifications)) |
|
| … | 1628 unchanged lines |
modified crates/hub/src/db/accounts.rs +88 −0
| … | 465 unchanged lines |
||
| 466 | 466 | Ok(()) |
|
| 467 | 467 | } |
|
| 468 | 468 | ||
| 469 | + | // ------------------------------------------------------- device sign-in |
|
| 470 | + | ||
| 471 | + | /// A device code an app asked for (oxygenhub#67): pending until the user |
|
| 472 | + | /// approves or denies it on the web. |
|
| 473 | + | #[derive(Clone, Debug, FromRow)] |
|
| 474 | + | pub struct DeviceCode { |
|
| 475 | + | pub id: i64, |
|
| 476 | + | pub device_hash: String, |
|
| 477 | + | pub user_code: String, |
|
| 478 | + | pub client: String, |
|
| 479 | + | pub scopes: String, |
|
| 480 | + | pub created_ms: i64, |
|
| 481 | + | pub expires_ms: i64, |
|
| 482 | + | pub last_poll_ms: i64, |
|
| 483 | + | pub account_id: Option<i64>, |
|
| 484 | + | pub approved_scopes: String, |
|
| 485 | + | pub denied: bool, |
|
| 486 | + | } |
|
| 487 | + | ||
| 488 | + | impl DeviceCode { |
|
| 489 | + | pub fn scope_list(&self) -> Vec<&str> { |
|
| 490 | + | self.scopes.split(',').filter(|s| !s.is_empty()).collect() |
|
| 491 | + | } |
|
| 492 | + | } |
|
| 493 | + | ||
| 494 | + | pub async fn create_device_code( |
|
| 495 | + | pool: &Pool, |
|
| 496 | + | device_hash: &str, |
|
| 497 | + | user_code: &str, |
|
| 498 | + | client: &str, |
|
| 499 | + | scopes: &str, |
|
| 500 | + | expires_ms: i64, |
|
| 501 | + | ) -> sqlx::Result<DeviceCode> { |
|
| 502 | + | // Expired codes go whenever a new one is made. |
|
| 503 | + | sqlx::query("DELETE FROM device_codes WHERE expires_ms < ?").bind(now_ms()).execute(pool).await?; |
|
| 504 | + | sqlx::query_as( |
|
| 505 | + | "INSERT INTO device_codes (device_hash, user_code, client, scopes, created_ms, expires_ms) VALUES (?, ?, ?, ?, ?, ?) RETURNING *", |
|
| 506 | + | ) |
|
| 507 | + | .bind(device_hash) |
|
| 508 | + | .bind(user_code) |
|
| 509 | + | .bind(client) |
|
| 510 | + | .bind(scopes) |
|
| 511 | + | .bind(now_ms()) |
|
| 512 | + | .bind(expires_ms) |
|
| 513 | + | .fetch_one(pool) |
|
| 514 | + | .await |
|
| 515 | + | } |
|
| 516 | + | ||
| 517 | + | pub async fn device_by_user_code(pool: &Pool, user_code: &str) -> sqlx::Result<Option<DeviceCode>> { |
|
| 518 | + | sqlx::query_as("SELECT * FROM device_codes WHERE user_code = ?").bind(user_code).fetch_optional(pool).await |
|
| 519 | + | } |
|
| 520 | + | ||
| 521 | + | pub async fn device_by_hash(pool: &Pool, device_hash: &str) -> sqlx::Result<Option<DeviceCode>> { |
|
| 522 | + | sqlx::query_as("SELECT * FROM device_codes WHERE device_hash = ?").bind(device_hash).fetch_optional(pool).await |
|
| 523 | + | } |
|
| 524 | + | ||
| 525 | + | /// Approve a pending code for an account with these scopes; false when it |
|
| 526 | + | /// was no longer pending. |
|
| 527 | + | pub async fn approve_device(pool: &Pool, id: i64, account_id: i64, scopes: &str) -> sqlx::Result<bool> { |
|
| 528 | + | let r = sqlx::query( |
|
| 529 | + | "UPDATE device_codes SET account_id = ?, approved_scopes = ? WHERE id = ? AND account_id IS NULL AND denied = 0 AND expires_ms >= ?", |
|
| 530 | + | ) |
|
| 531 | + | .bind(account_id) |
|
| 532 | + | .bind(scopes) |
|
| 533 | + | .bind(id) |
|
| 534 | + | .bind(now_ms()) |
|
| 535 | + | .execute(pool) |
|
| 536 | + | .await?; |
|
| 537 | + | Ok(r.rows_affected() == 1) |
|
| 538 | + | } |
|
| 539 | + | ||
| 540 | + | pub async fn deny_device(pool: &Pool, id: i64) -> sqlx::Result<()> { |
|
| 541 | + | sqlx::query("UPDATE device_codes SET denied = 1 WHERE id = ? AND account_id IS NULL").bind(id).execute(pool).await?; |
|
| 542 | + | Ok(()) |
|
| 543 | + | } |
|
| 544 | + | ||
| 545 | + | pub async fn device_polled(pool: &Pool, id: i64) -> sqlx::Result<()> { |
|
| 546 | + | sqlx::query("UPDATE device_codes SET last_poll_ms = ? WHERE id = ?").bind(now_ms()).bind(id).execute(pool).await?; |
|
| 547 | + | Ok(()) |
|
| 548 | + | } |
|
| 549 | + | ||
| 550 | + | /// Remove a code; true when this call removed it (so one poll gets the |
|
| 551 | + | /// token, however many race). |
|
| 552 | + | pub async fn delete_device(pool: &Pool, id: i64) -> sqlx::Result<bool> { |
|
| 553 | + | let r = sqlx::query("DELETE FROM device_codes WHERE id = ?").bind(id).execute(pool).await?; |
|
| 554 | + | Ok(r.rows_affected() == 1) |
|
| 555 | + | } |
|
| 556 | + | ||
| 469 | 557 | // -------------------------------------------------------------- identities |
|
| 470 | 558 | ||
| 471 | 559 | pub async fn identities_of(pool: &Pool, account_id: i64) -> sqlx::Result<Vec<LinkedIdentity>> { |
|
| … | 254 unchanged lines |
modified crates/hub/src/web/mod.rs +3 −1
| … | 5 unchanged lines |
||
| 6 | 6 | mod admin; |
|
| 7 | 7 | mod avatar; |
|
| 8 | 8 | pub mod chain; |
|
| 9 | + | mod device; |
|
| 9 | 10 | pub mod edit; |
|
| 10 | 11 | pub mod filters; |
|
| 11 | 12 | pub mod flow; |
|
| … | 15 unchanged lines |
||
| 27 | 28 | mod resolve; |
|
| 28 | 29 | pub mod runners; |
|
| 29 | 30 | mod session; |
|
| 30 | - | mod settings; |
|
| 31 | + | pub(crate) mod settings; |
|
| 31 | 32 | mod statics; |
|
| 32 | 33 | mod webhooks; |
|
| 33 | 34 | ||
| … | 24 unchanged lines |
||
| 58 | 59 | .route("/favicon.ico", get(statics::favicon)) |
|
| 59 | 60 | .route("/static/{*path}", get(statics::asset)) |
|
| 60 | 61 | .route("/login", get(session::login_form).post(session::login)) |
|
| 62 | + | .route("/device", get(device::form).post(device::answer)) |
|
| 61 | 63 | .route("/logout", post(session::logout)) |
|
| 62 | 64 | .route("/register", get(session::register_form).post(session::register)) |
|
| 63 | 65 | .route("/settings", get(settings::profile).post(settings::update_profile)) |
|
| … | 544 unchanged lines |
modified crates/hub/tests/api.rs +59 −0
| … | 1130 unchanged lines |
||
| 1131 | 1131 | assert!(about.contains(&format!("<strong>{}</strong>", env!("CARGO_PKG_VERSION"))) && about.contains("oxy-core"), "{about}"); |
|
| 1132 | 1132 | assert!(about.contains("href=\"/about\""), "the footer links here"); |
|
| 1133 | 1133 | } |
|
| 1134 | + | ||
| 1135 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 1136 | + | async fn device_sign_in() { |
|
| 1137 | + | let h = hub().await; |
|
| 1138 | + | let app = &h.app; |
|
| 1139 | + | let r = |
|
| 1140 | + | call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")).await; |
|
| 1141 | + | let andy = r.cookie.unwrap(); |
|
| 1142 | + | ||
| 1143 | + | // An unknown scope is refused. |
|
| 1144 | + | let (st, v) = api(app, "POST", "/api/v1/device/code", None, Some(r#"{"client":"x","scopes":["root"]}"#)).await; |
|
| 1145 | + | assert_eq!((st, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("invalid_scope"))); |
|
| 1146 | + | ||
| 1147 | + | // The app asks for a code and polls; nobody has approved it yet. |
|
| 1148 | + | let (st, v) = api(app, "POST", "/api/v1/device/code", None, Some(r#"{"client":"Phone app","scopes":["repo:read","user:read"]}"#)).await; |
|
| 1149 | + | assert_eq!(st, StatusCode::OK, "{v}"); |
|
| 1150 | + | let device = v["device_code"].as_str().unwrap().to_string(); |
|
| 1151 | + | let user = v["user_code"].as_str().unwrap().to_string(); |
|
| 1152 | + | assert_eq!(v["verification_uri_complete"], format!("{}/device?code={user}", h.base)); |
|
| 1153 | + | let poll = format!(r#"{{"device_code":"{device}"}}"#); |
|
| 1154 | + | let (st, v) = api(app, "POST", "/api/v1/device/token", None, Some(&poll)).await; |
|
| 1155 | + | assert_eq!((st, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("authorization_pending"))); |
|
| 1156 | + | let (_, v) = api(app, "POST", "/api/v1/device/token", None, Some(&poll)).await; |
|
| 1157 | + | assert_eq!(v["error"], "slow_down", "polled too soon"); |
|
| 1158 | + | ||
| 1159 | + | // The user approves it on the web, allowing less than it asked for. |
|
| 1160 | + | let page = call(app, "GET", &format!("/device?code={}", user.to_lowercase().replace('-', "")), None, Some(&andy), None, None).await; |
|
| 1161 | + | assert!(page.body.contains("Phone app") && page.body.contains("repo:read"), "{}", page.body); |
|
| 1162 | + | let csrf = csrf_of(&page.body); |
|
| 1163 | + | let r = |
|
| 1164 | + | call(app, "POST", "/device", None, Some(&andy), None, Some(&format!("_csrf={csrf}&code={user}&action=approve&scopes=user%3Aread"))) |
|
| 1165 | + | .await; |
|
| 1166 | + | assert!(r.body.contains("Approved"), "{}", r.body); |
|
| 1167 | + | ||
| 1168 | + | // The next poll gets a token with what was allowed, once. |
|
| 1169 | + | let (st, v) = api(app, "POST", "/api/v1/device/token", None, Some(&poll)).await; |
|
| 1170 | + | assert_eq!(st, StatusCode::OK, "{v}"); |
|
| 1171 | + | assert_eq!((v["scope"].as_str(), v["username"].as_str()), (Some("user:read"), Some("andy"))); |
|
| 1172 | + | let token = v["access_token"].as_str().unwrap().to_string(); |
|
| 1173 | + | let (st, v) = api(app, "GET", "/api/v1/user", Some(&token), None).await; |
|
| 1174 | + | assert_eq!((st, v["username"].as_str()), (StatusCode::OK, Some("andy"))); |
|
| 1175 | + | let (_, v) = api(app, "POST", "/api/v1/device/token", None, Some(&poll)).await; |
|
| 1176 | + | assert_eq!(v["error"], "expired_token", "a code gives one token"); |
|
| 1177 | + | let tokens = call(app, "GET", "/settings/tokens", None, Some(&andy), None, None).await; |
|
| 1178 | + | assert!(tokens.body.contains("device: Phone app")); |
|
| 1179 | + | ||
| 1180 | + | // A denied code says so. |
|
| 1181 | + | let (_, v) = api(app, "POST", "/api/v1/device/code", None, Some(r#"{"client":"Other"}"#)).await; |
|
| 1182 | + | let (device, user) = (v["device_code"].as_str().unwrap().to_string(), v["user_code"].as_str().unwrap().to_string()); |
|
| 1183 | + | let page = call(app, "GET", &format!("/device?code={user}"), None, Some(&andy), None, None).await; |
|
| 1184 | + | let csrf = csrf_of(&page.body); |
|
| 1185 | + | call(app, "POST", "/device", None, Some(&andy), None, Some(&format!("_csrf={csrf}&code={user}&action=deny"))).await; |
|
| 1186 | + | let (_, v) = api(app, "POST", "/api/v1/device/token", None, Some(&format!(r#"{{"device_code":"{device}"}}"#))).await; |
|
| 1187 | + | assert_eq!(v["error"], "access_denied"); |
|
| 1188 | + | ||
| 1189 | + | // Signed out, the page asks for a sign-in first. |
|
| 1190 | + | let r = call(app, "GET", "/device", None, None, None, None).await; |
|
| 1191 | + | assert!(r.status.is_redirection(), "{}", r.status); |
|
| 1192 | + | } |
modified crates/oh/src/client.rs +11 −0
| … | 68 unchanged lines |
||
| 69 | 69 | serde_json::from_str(&text).with_context(|| format!("{method} {path}: unexpected answer")) |
|
| 70 | 70 | } |
|
| 71 | 71 | ||
| 72 | + | /// POST JSON, returning the status and body whatever the status (the |
|
| 73 | + | /// device sign-in's answers while it waits are errors by design). |
|
| 74 | + | pub fn post_raw(&self, path: &str, body: &impl Serialize) -> Result<(u16, String)> { |
|
| 75 | + | let json = serde_json::to_string(body)?; |
|
| 76 | + | match self.req("POST", path).set("Content-Type", "application/json").send_string(&json) { |
|
| 77 | + | Ok(r) => Ok((r.status(), r.into_string()?)), |
|
| 78 | + | Err(ureq::Error::Status(code, r)) => Ok((code, r.into_string().unwrap_or_default())), |
|
| 79 | + | Err(e) => Err(e).context(format!("POST {path}")), |
|
| 80 | + | } |
|
| 81 | + | } |
|
| 82 | + | ||
| 72 | 83 | /// POST raw bytes (a release asset). |
|
| 73 | 84 | pub fn upload(&self, path: &str, bytes: &[u8]) -> Result<String> { |
|
| 74 | 85 | Self::finish( |
|
| … | 5 unchanged lines |
modified crates/oh/src/main.rs +56 −6
| … | 81 unchanged lines |
||
| 82 | 82 | ||
| 83 | 83 | #[derive(Subcommand)] |
|
| 84 | 84 | enum AuthCmd { |
|
| 85 | - | /// Save a token for a hub (read from stdin when not given) and make it the default |
|
| 85 | + | /// Sign in to a hub and make it the default: approve a code on the web |
|
| 86 | + | /// (device sign-in), or give a token |
|
| 86 | 87 | Login { |
|
| 87 | - | /// The hub, e.g. https://earth.example:7343 |
|
| 88 | + | /// The hub, e.g. https://oxygenhub.io |
|
| 88 | 89 | #[arg(long = "with-hub", value_name = "URL")] |
|
| 89 | 90 | url: String, |
|
| 90 | - | /// An API token from the hub's Settings → Tokens |
|
| 91 | + | /// An API token from the hub's Settings → Tokens, instead of |
|
| 92 | + | /// approving a code ("-" reads it from stdin) |
|
| 91 | 93 | #[arg(long)] |
|
| 92 | 94 | token: Option<String>, |
|
| 93 | 95 | /// The hub's CA certificate, when self-signed |
|
| … | 204 unchanged lines |
||
| 298 | 300 | ||
| 299 | 301 | fn login(mut cfg: Config, url: &str, token: Option<String>, ca: Option<std::path::PathBuf>) -> Result<i32> { |
|
| 300 | 302 | let hub = url.trim_end_matches('/').to_string(); |
|
| 301 | - | let token = match token { |
|
| 302 | - | Some(t) => t, |
|
| 303 | - | None => { |
|
| 303 | + | let token = match token.as_deref() { |
|
| 304 | + | Some("-") => { |
|
| 304 | 305 | eprintln!("Paste a token from {hub}/settings/tokens, then press Enter:"); |
|
| 305 | 306 | let mut line = String::new(); |
|
| 306 | 307 | std::io::stdin().read_line(&mut line)?; |
|
| 307 | 308 | line.trim().to_string() |
|
| 308 | 309 | } |
|
| 310 | + | Some(t) => t.to_string(), |
|
| 311 | + | None => device_sign_in(&hub, ca.clone())?, |
|
| 309 | 312 | }; |
|
| 310 | 313 | if token.is_empty() { |
|
| 311 | 314 | bail!("no token given"); |
|
| … | 7 unchanged lines |
||
| 319 | 322 | Ok(0) |
|
| 320 | 323 | } |
|
| 321 | 324 | ||
| 325 | + | /// Device sign-in (oxygenhub#67): the hub gives a code, the user approves |
|
| 326 | + | /// it on the web, and the token arrives here. |
|
| 327 | + | fn device_sign_in(hub: &str, ca: Option<std::path::PathBuf>) -> Result<String> { |
|
| 328 | + | let t = Target { hub: hub.to_string(), token: None, ca, owner: String::new(), name: String::new() }; |
|
| 329 | + | let c = Client::new(&t)?; |
|
| 330 | + | let host = std::fs::read_to_string("/etc/hostname") |
|
| 331 | + | .ok() |
|
| 332 | + | .or_else(|| std::env::var("COMPUTERNAME").ok()) |
|
| 333 | + | .map(|h| h.trim().to_string()) |
|
| 334 | + | .filter(|h| !h.is_empty()); |
|
| 335 | + | let client = match host { |
|
| 336 | + | Some(h) => format!("oh on {h}"), |
|
| 337 | + | None => "oh".to_string(), |
|
| 338 | + | }; |
|
| 339 | + | let scopes = ["repo:read", "repo:write", "proposals:write", "issues:write", "user:read"]; |
|
| 340 | + | let (code, body) = c.post_raw("/device/code", &serde_json::json!({ "client": client, "scopes": scopes }))?; |
|
| 341 | + | let v: serde_json::Value = serde_json::from_str(&body).context("the hub's answer to a device code request")?; |
|
| 342 | + | if code != 200 { |
|
| 343 | + | bail!("{hub} cannot sign in a device: {}", v["error_description"].as_str().or(v["detail"].as_str()).unwrap_or(&body)); |
|
| 344 | + | } |
|
| 345 | + | let device = v["device_code"].as_str().context("no device code")?.to_string(); |
|
| 346 | + | let user = v["user_code"].as_str().unwrap_or("?"); |
|
| 347 | + | let url = v["verification_uri_complete"].as_str().or(v["verification_uri"].as_str()).unwrap_or(hub); |
|
| 348 | + | let mut interval = v["interval"].as_u64().unwrap_or(5).max(1); |
|
| 349 | + | let deadline = std::time::Instant::now() + std::time::Duration::from_secs(v["expires_in"].as_u64().unwrap_or(900)); |
|
| 350 | + | eprintln!("Open {} and approve the code {}", ui::strong(url), ui::strong(user)); |
|
| 351 | + | eprintln!("{}", ui::dim("Waiting for approval (Ctrl-C to give up)…")); |
|
| 352 | + | loop { |
|
| 353 | + | std::thread::sleep(std::time::Duration::from_secs(interval)); |
|
| 354 | + | if std::time::Instant::now() > deadline { |
|
| 355 | + | bail!("the code expired before it was approved; run oh auth login again"); |
|
| 356 | + | } |
|
| 357 | + | let (code, body) = c.post_raw("/device/token", &serde_json::json!({ "device_code": device }))?; |
|
| 358 | + | let v: serde_json::Value = serde_json::from_str(&body).unwrap_or_default(); |
|
| 359 | + | if code == 200 { |
|
| 360 | + | return v["access_token"].as_str().map(String::from).context("no token in the hub's answer"); |
|
| 361 | + | } |
|
| 362 | + | match v["error"].as_str() { |
|
| 363 | + | Some("authorization_pending") => {} |
|
| 364 | + | Some("slow_down") => interval += 5, |
|
| 365 | + | Some("access_denied") => bail!("the sign-in was denied on the web"), |
|
| 366 | + | Some("expired_token") => bail!("the code expired before it was approved; run oh auth login again"), |
|
| 367 | + | _ => bail!("signing in: HTTP {code}: {body}"), |
|
| 368 | + | } |
|
| 369 | + | } |
|
| 370 | + | } |
|
| 371 | + | ||
| 322 | 372 | fn body_of(body: Option<String>, file: Option<String>) -> Result<String> { |
|
| 323 | 373 | match (body, file) { |
|
| 324 | 374 | (Some(b), _) => Ok(b), |
|
| … | 393 unchanged lines |
modified docs/oh.md +8 −5
| … | 20 unchanged lines |
||
| 21 | 21 | oh issue list --repo andymccall/oxygen |
|
| 22 | 22 | ``` |
|
| 23 | 23 | ||
| 24 | - | `oh auth login` checks the token, saves it with the CA in |
|
| 25 | - | `~/.config/oh/config.toml` (owner-only), and makes that hub the default. It |
|
| 26 | - | reads the token from stdin unless `--token` is given. `OH_TOKEN` (or |
|
| 27 | - | `OXY_TOKEN`), `OH_HUB` and `OXY_CA` override the saved values. |
|
| 24 | + | `oh auth login` shows a code and a link: open it, signed in to the hub, and |
|
| 25 | + | approve the code, choosing what `oh` may do (device sign-in). The token |
|
| 26 | + | arrives by itself; `oh` checks it, saves it with the CA in |
|
| 27 | + | `~/.config/oh/config.toml` (owner-only), and makes that hub the default. |
|
| 28 | + | `--token TOKEN` uses a token from Settings → Tokens instead, and |
|
| 29 | + | `--token -` reads one from stdin. `OH_TOKEN` (or `OXY_TOKEN`), `OH_HUB` and |
|
| 30 | + | `OXY_CA` override the saved values. |
|
| 28 | 31 | ||
| 29 | 32 | ## Commands |
|
| 30 | 33 | ||
| 31 | 34 | | Command | What it does | |
|
| 32 | 35 | |---|---| |
|
| 33 | - | | `oh auth login`, `oh auth status` | Save a token; show who you are | |
|
| 36 | + | | `oh auth login`, `oh auth status` | Sign in (approve a code on the web, or give a token); show who you are | |
|
| 34 | 37 | | `oh repo view` | Name, description, visibility, default bookmark, clone command | |
|
| 35 | 38 | | `oh issue list [--state open\|closed\|all]` | Issues | |
|
| 36 | 39 | | `oh issue view N` | An issue with its comments | |
|
| … | 23 unchanged lines |
modified docs/spec.md +2 −0
| … | 202 unchanged lines |
||
| 203 | 203 | ||
| 204 | 204 | Tokens are 32 random bytes, shown once, stored as BLAKE3 hashes, with a name, an optional expiry, and scopes: `repo:read`, `repo:write`, `repo:admin`, `issues:write`, `proposals:write`, `user:read`, `hooks:admin`, optionally restricted to listed repositories. Tokens authenticate the API and the wire protocol; they never authenticate the pages. |
|
| 205 | 205 | ||
| 206 | + | **Device sign-in** (in the shape of RFC 8628) gives a token to an app that cannot take a password, such as `oh` or a phone client. The app posts `{client, scopes}` to `/api/v1/device/code` without a token and gets a device code (kept, hashed, by the hub), a user code `XXXX-XXXX` (consonants), `verification_uri` (`/device`), `verification_uri_complete`, `expires_in` (15 minutes) and `interval` (5 seconds). It shows the user code and link, and polls `/api/v1/device/token` with `{device_code}`, getting `authorization_pending`, `slow_down` (polled within the interval), `access_denied` or `expired_token` as `{error, error_description}` with 400. The user opens `/device`, signed in (with their second factor), sees the app's name and the scopes it asked for, and approves with any non-empty subset of them, or denies. The next poll makes a token named `device: CLIENT` with the approved scopes and returns `{access_token, token_type: "bearer", scope, username}`, once: the code is then gone. No token exists until that poll, so none is stored in the clear. Approvals, denials and device tokens are in the audit log. |
|
| 207 | + | ||
| 206 | 208 | ## 6. Web interface |
|
| 207 | 209 | ||
| 208 | 210 | ### 6.1 Theme |
|
| … | 428 unchanged lines |