OXYGENHUB

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