OXYGENHUB

Device tokens expire and renew on use; the approval page shows the requester (closes #70) 60f11b71a740

A token from device sign-in now expires 90 days after its last use: each
use moves its expiry on (tokens.renew_ms), and Settings -> Tokens says so.
A device code records the address that asked for it, from the proxy's
headers when server.behind_proxy is set and the peer's otherwise; the
approval page shows it with when the code was asked for, and warns to
approve only a code you started yourself. DELETE /api/v1/user/token
revokes the token it is made with, for oxy auth logout.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-01 13:33 UTC
Signature
✔ signed by Andy McCall
Hash
60f11b71a74091b01385c654b0011014d4036deb49e2576b6d2691242cb260bd
Depends on
0b20f1ec4007 19bb31ff1f5c b5668108487f b64e2537d702 d23464c0ac60 df71c7892d27 e9bfea8e26db
Depended on by
aeed63c00ad7 5714347a6233 f4e138301198 ecac6e8d9b41 05f1a85a2842 c92a99490cba 5d90e0e045e2 00ef7b83b340 bff9106edcba 2bbe88a85860
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
34 atoms, 1 tree operation, 10 files

Browse the tree at this change

added crates/hub/migrations/0030_device_token_life.sql +5 −0
1 +
-- oxygenhub#70: tokens from device sign-in expire, and each use renews
2 +
-- them (renew_ms: the lifetime a use extends the token to); a device code
3 +
-- keeps the address that asked for it, shown when it is approved.
4 +
ALTER TABLE tokens ADD COLUMN renew_ms INTEGER;
5 +
ALTER TABLE device_codes ADD COLUMN requester TEXT NOT NULL DEFAULT '';
modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Device tokens expire, and the approval page says where a request came from (#70).** A token from device sign-in lasts 90 days past its last use; Settings → Tokens shows its expiry, renewed on use. The `/device` page shows when the code was asked for and from which address (the client's, through the proxy when `behind_proxy` is set), and warns to approve only a code you started yourself. `DELETE /api/v1/user/token` revokes the token it is called with, which `oxy auth logout` uses.
8 +
7 9
- **Device sign-in (#67).** An app gets a token without a pasted secret. It asks `/api/v1/device/code` for a code and shows it; you approve the code at `/device`, signed in with your second factor, choosing what the app may do; its next poll gets the token. `oh auth login` signs in this way; `--token` still takes a token.
8 10
- **Review requests (#68).** The author or a writer asks someone to review under Reviewers on the proposal page, or with `POST .../proposals/{number}/reviewers`. The person asked is notified, and their review answers the request. Every review now tells the author.
9 11
- **Runner scopes (#41, part 2).** A runner serves the whole site, one owner's repositories, or one repository, set when its registration token is made or later on Admin → Runners. It never takes another scope's jobs.
…
9 unchanged lines
modified crates/hub/src/api/device.rs +18 −3
…
11 unchanged lines
12 12
use serde::Deserialize;
13 13
use serde_json::json;
14 14
15 +
/// How long a device's token lasts after its last use (oxygenhub#70).
16 +
pub const TOKEN_LIFE_MS: i64 = 90 * 24 * 3600 * 1000;
15 17
/// How long a code lasts, and how often an app may poll.
16 18
const LIFETIME_MS: i64 = 15 * 60 * 1000;
17 19
const INTERVAL_S: i64 = 5;
…
35 unchanged lines
53 55
54 56
/// `POST /api/v1/device/code`: a device code to poll with and a user code
55 57
/// to show. No token needed.
56 -
pub async fn code(State(app): State<Shared>, Json(req): Json<CodeRequest>) -> Response {
58 +
pub async fn code(
59 +
    State(app): State<Shared>,
60 +
    headers: axum::http::HeaderMap,
61 +
    extensions: axum::http::Extensions,
62 +
    Json(req): Json<CodeRequest>,
63 +
) -> Response {
64 +
    let requester = super::client_addr(&app, &headers, &extensions);
57 65
    let known = |s: &str| crate::web::settings::SCOPES.iter().any(|(k, _)| *k == s);
58 66
    let mut scopes: Vec<&str> = req.scopes.iter().map(String::as_str).filter(|s| known(s)).collect();
59 67
    if scopes.len() != req.scopes.len() {
…
10 unchanged lines
70 78
    // A clash of user codes is unlikely; try a few.
71 79
    for _ in 0..4 {
72 80
        let user = user_code();
73 -
        match accounts::create_device_code(&app.db, &hash, &user, &client, &scopes.join(","), expires).await {
81 +
        match accounts::create_device_code(&app.db, &hash, &user, &client, &scopes.join(","), expires, &requester).await {
74 82
            Ok(_) => {
75 83
                let base = app.config.base_url().trim_end_matches('/').to_string();
76 84
                return Json(json!({
…
57 unchanged lines
134 142
    };
135 143
    let (secret, token_hash) = crate::auth::new_token();
136 144
    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 {
145 +
    // A device's token lasts while it is used: each use renews it.
146 +
    let expires = now + TOKEN_LIFE_MS;
147 +
    let made = match accounts::create_token(&app.db, account_id, &name, &token_hash, &d.approved_scopes, "", Some(expires)).await {
148 +
        Ok(t) => t,
149 +
        Err(e) => return oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()),
150 +
    };
151 +
    if let Err(e) = accounts::set_token_renewal(&app.db, made.id, TOKEN_LIFE_MS).await {
138 152
        return oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string());
139 153
    }
140 154
    accounts::audit(&app.db, Some(&account), "token.device", &name, &d.approved_scopes).await.ok();
…
2 unchanged lines
143 157
        "token_type": "bearer",
144 158
        "scope": d.approved_scopes.replace(',', " "),
145 159
        "username": account.username,
160 +
        "expires_in": TOKEN_LIFE_MS / 1000,
146 161
    }))
147 162
    .into_response()
148 163
}
modified crates/hub/src/api/mod.rs +41 −2
…
46 unchanged lines
47 47
        .route("/device/code", post(device::code))
48 48
        .route("/device/token", post(device::token))
49 49
        .route("/user", get(me))
50 +
        .route("/user/token", axum::routing::delete(revoke_own_token))
50 51
        .route("/version", get(version))
51 52
        .route("/notifications", get(notifications))
52 53
        .route("/notifications/read", post(notifications_read_all))
…
132 unchanged lines
185 186
    ApiError(StatusCode::FORBIDDEN, what.into())
186 187
}
187 188
189 +
/// The address a request came from: the client's, as the proxy reports
190 +
/// it when the hub is behind one (`server.behind_proxy`), else the peer's.
191 +
/// Only an address's characters are kept, so it is safe to show.
192 +
pub fn client_addr(app: &Shared, headers: &HeaderMap, extensions: &axum::http::Extensions) -> String {
193 +
    let from_proxy = || {
194 +
        ["cf-connecting-ip", "x-forwarded-for", "x-real-ip"]
195 +
            .iter()
196 +
            .find_map(|h| headers.get(*h).and_then(|v| v.to_str().ok()).and_then(|v| v.split(',').next()).map(|v| v.trim().to_string()))
197 +
    };
198 +
    let addr = if app.config.server.behind_proxy {
199 +
        from_proxy()
200 +
    } else {
201 +
        extensions.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>().map(|c| c.0.ip().to_string())
202 +
    };
203 +
    addr.map(|a| a.chars().filter(|c| c.is_ascii_hexdigit() || matches!(c, '.' | ':')).take(45).collect()).unwrap_or_default()
204 +
}
205 +
206 +
/// `DELETE /api/v1/user/token`: revoke the token this request is made
207 +
/// with, as `oxy auth logout` does.
208 +
async fn revoke_own_token(State(app): State<Shared>, headers: HeaderMap) -> Result<StatusCode, ApiError> {
209 +
    let c = caller(&app, &headers).await?;
210 +
    let (Some(account), Some(id)) = (c.account.as_ref(), c.token_id) else {
211 +
        return Err(ApiError(StatusCode::UNAUTHORIZED, "revoking needs the personal access token to revoke".into()));
212 +
    };
213 +
    db::accounts::delete_token(&app.db, account.id, id).await?;
214 +
    db::accounts::audit(&app.db, Some(account), "token.revoke", &c.token, "").await.ok();
215 +
    Ok(StatusCode::NO_CONTENT)
216 +
}
217 +
188 218
/// Who calls: the account behind a bearer token, with the token's scopes.
189 219
pub struct Caller {
190 220
    pub account: Option<Account>,
…
1 unchanged lines
192 222
    pub site: bool,
193 223
    /// The token's name, for records of what a call did.
194 224
    pub token: String,
225 +
    /// The personal access token's id, when one is used.
226 +
    pub token_id: Option<i64>,
195 227
}
196 228
197 229
impl Caller {
…
16 unchanged lines
214 246
215 247
pub async fn caller(app: &Shared, headers: &HeaderMap) -> Result<Caller, ApiError> {
216 248
    let Some(t) = oxy_server::bearer_token(headers) else {
217 -
        return Ok(Caller { account: None, scopes: vec![], site: false, token: String::new() });
249 +
        return Ok(Caller { account: None, scopes: vec![], site: false, token: String::new(), token_id: None });
218 250
    };
219 251
    if app.config.auth.push_tokens.iter().any(|x| auth::mac_equal(x, t)) {
220 252
        let admin = db::accounts::accounts(&app.db).await?.into_iter().find(|a| a.is_admin);
…
2 unchanged lines
223 255
            scopes: vec!["repo:admin".into(), "user:read".into(), "checks:write".into()],
224 256
            site: true,
225 257
            token: "site token".into(),
258 +
            token_id: None,
226 259
        });
227 260
    }
228 261
    let token = db::accounts::token_by_hash(&app.db, &auth::token_hash(t)).await?;
…
1 unchanged lines
230 263
        Some(tok) if !tok.expired(db::now_ms()) => {
231 264
            let account = db::accounts::account_by_id(&app.db, tok.account_id).await?.filter(|a| !a.banned);
232 265
            db::accounts::touch_token(&app.db, tok.id).await.ok();
233 -
            Ok(Caller { account, scopes: tok.scope_list().iter().map(|s| s.to_string()).collect(), site: false, token: tok.name.clone() })
266 +
            Ok(Caller {
267 +
                account,
268 +
                scopes: tok.scope_list().iter().map(|s| s.to_string()).collect(),
269 +
                site: false,
270 +
                token: tok.name.clone(),
271 +
                token_id: Some(tok.id),
272 +
            })
234 273
        }
235 274
        _ => Err(ApiError(StatusCode::UNAUTHORIZED, "unknown or expired token".into())),
236 275
    }
…
1482 unchanged lines
modified crates/hub/src/db/accounts.rs +23 −2
…
75 unchanged lines
76 76
    pub created_ms: i64,
77 77
    pub expires_ms: Option<i64>,
78 78
    pub last_used_ms: Option<i64>,
79 +
    /// A renewing token's lifetime: each use extends it to now plus this.
80 +
    pub renew_ms: Option<i64>,
79 81
}
80 82
81 83
impl Token {
…
379 unchanged lines
461 463
    Ok(())
462 464
}
463 465
466 +
/// Record a use; a renewing token's expiry moves to now plus its lifetime.
464 467
pub async fn touch_token(pool: &Pool, id: i64) -> sqlx::Result<()> {
465 -
    sqlx::query("UPDATE tokens SET last_used_ms = ? WHERE id = ?").bind(now_ms()).bind(id).execute(pool).await?;
468 +
    sqlx::query(
469 +
        "UPDATE tokens SET last_used_ms = ?1, \
470 +
         expires_ms = CASE WHEN renew_ms IS NULL THEN expires_ms ELSE MAX(COALESCE(expires_ms, 0), ?1 + renew_ms) END \
471 +
         WHERE id = ?2",
472 +
    )
473 +
    .bind(now_ms())
474 +
    .bind(id)
475 +
    .execute(pool)
476 +
    .await?;
477 +
    Ok(())
478 +
}
479 +
480 +
/// Make a token renewing: it lasts `renew_ms` past its last use.
481 +
pub async fn set_token_renewal(pool: &Pool, id: i64, renew_ms: i64) -> sqlx::Result<()> {
482 +
    sqlx::query("UPDATE tokens SET renew_ms = ? WHERE id = ?").bind(renew_ms).bind(id).execute(pool).await?;
466 483
    Ok(())
467 484
}
468 485
…
14 unchanged lines
483 500
    pub account_id: Option<i64>,
484 501
    pub approved_scopes: String,
485 502
    pub denied: bool,
503 +
    /// The address that asked for the code, as the hub saw it.
504 +
    pub requester: String,
486 505
}
487 506
488 507
impl DeviceCode {
…
9 unchanged lines
498 517
    client: &str,
499 518
    scopes: &str,
500 519
    expires_ms: i64,
520 +
    requester: &str,
501 521
) -> sqlx::Result<DeviceCode> {
502 522
    // Expired codes go whenever a new one is made.
503 523
    sqlx::query("DELETE FROM device_codes WHERE expires_ms < ?").bind(now_ms()).execute(pool).await?;
504 524
    sqlx::query_as(
505 -
        "INSERT INTO device_codes (device_hash, user_code, client, scopes, created_ms, expires_ms) VALUES (?, ?, ?, ?, ?, ?) RETURNING *",
525 +
        "INSERT INTO device_codes (device_hash, user_code, client, scopes, created_ms, expires_ms, requester) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *",
506 526
    )
507 527
    .bind(device_hash)
508 528
    .bind(user_code)
…
1 unchanged lines
510 530
    .bind(scopes)
511 531
    .bind(now_ms())
512 532
    .bind(expires_ms)
533 +
    .bind(requester)
513 534
    .fetch_one(pool)
514 535
    .await
515 536
}
…
298 unchanged lines
modified crates/hub/src/lib.rs +9 −2
…
84 unchanged lines
85 85
        let listener = tokio::net::TcpListener::bind(addr).await?;
86 86
        tracing::info!("http on {addr}");
87 87
        let r = router.clone();
88 -
        tasks.spawn(async move { axum::serve(listener, r).await.map_err(anyhow::Error::from) });
88 +
        tasks.spawn(async move {
89 +
            axum::serve(listener, r.into_make_service_with_connect_info::<std::net::SocketAddr>()).await.map_err(anyhow::Error::from)
90 +
        });
89 91
    }
90 92
    if let Some(addr) = https {
91 93
        let _ = rustls::crypto::ring::default_provider().install_default();
…
2 unchanged lines
94 96
            .map_err(|e| anyhow::anyhow!("loading TLS files: {e}"))?;
95 97
        tracing::info!("https on {addr}");
96 98
        let r = router.clone();
97 -
        tasks.spawn(async move { axum_server::bind_rustls(addr, tls).serve(r.into_make_service()).await.map_err(anyhow::Error::from) });
99 +
        tasks.spawn(async move {
100 +
            axum_server::bind_rustls(addr, tls)
101 +
                .serve(r.into_make_service_with_connect_info::<std::net::SocketAddr>())
102 +
                .await
103 +
                .map_err(anyhow::Error::from)
104 +
        });
98 105
    }
99 106
    tokio::select! {
100 107
        r = tasks.join_next() => { r.transpose()?.transpose()?; Ok(()) }
…
3 unchanged lines
modified crates/hub/src/web/device.rs +1 −1
…
2 unchanged lines
3 3
//! for, and approves it with the scopes they choose, or denies it.
4 4
5 5
use super::settings::{csrf, require, SCOPES};
6 -
use super::{Error, Page, Shell};
6 +
use super::{filters, Error, Page, Shell};
7 7
use crate::api::device::normalize_user_code;
8 8
use crate::auth::Viewer;
9 9
use crate::db::{self, accounts};
…
102 unchanged lines
modified crates/hub/tests/api.rs +10 −0
…
1217 unchanged lines
1218 1218
    assert_eq!(v["error"], "expired_token", "a code gives one token");
1219 1219
    let tokens = call(app, "GET", "/settings/tokens", None, Some(&andy), None, None).await;
1220 1220
    assert!(tokens.body.contains("device: Phone app"));
1221 +
    // oxygenhub#70: a device's token expires, and each use renews it.
1222 +
    assert!(tokens.body.contains("renewed on use"), "{}", tokens.body);
1221 1223
1224 +
    // The token can revoke itself (oxy auth logout), and is then refused.
1225 +
    let (st, _) = api(app, "DELETE", "/api/v1/user/token", Some(&token), None).await;
1226 +
    assert_eq!(st, StatusCode::NO_CONTENT);
1227 +
    let (st, _) = api(app, "GET", "/api/v1/user", Some(&token), None).await;
1228 +
    assert_eq!(st, StatusCode::UNAUTHORIZED);
1229 +
    let (st, _) = api(app, "DELETE", "/api/v1/user/token", None, None).await;
1230 +
    assert_eq!(st, StatusCode::UNAUTHORIZED, "revoking needs a token");
1231 +
1222 1232
    // A denied code says so.
1223 1233
    let (_, v) = api(app, "POST", "/api/v1/device/code", None, Some(r#"{"client":"Other"}"#)).await;
1224 1234
    let (device, user) = (v["device_code"].as_str().unwrap().to_string(), v["user_code"].as_str().unwrap().to_string());
…
10 unchanged lines
modified templates/device.html +3 −1
…
8 unchanged lines
9 9
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
10 10
  <input type="hidden" name="code" value="{{ d.user_code }}">
11 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>
12 +
  <p class="small">Code <code>{{ d.user_code }}</code>, asked for <time title="{{ d.created_ms|when_i }}">{{ d.created_ms|ago_i }}</time>{% if !d.requester.is_empty() %} from <code>{{ d.requester }}</code>{% endif %}.</p>
13 +
  <p class="warn small">Approve only a code you started yourself, just now, on your own device. If someone sent you this code or link, deny it: they are asking for access to your account.</p>
14 +
  <p class="small">The app gets a token with what you allow here. It expires after 90 days without use, and you can revoke it under Settings → Tokens.</p>
13 15
  <label>Allow it to</label>
14 16
  <div class="scopes">
15 17
    {% 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 %}
…
13 unchanged lines
modified templates/settings_tokens.html +1 −1
…
16 unchanged lines
17 17
<tr><td>{{ t.name }}</td><td>{{ t.scopes }}</td><td>{% if t.repos.is_empty() %}<span class="mute">all</span>{% else %}{{ t.repos }}{% endif %}</td>
18 18
<td><time title="{{ t.created_ms|when_i }}">{{ t.created_ms|ago_i }}</time></td>
19 19
<td>{% if let Some(u) = t.last_used_ms %}<time title="{{ u|when_i }}">{{ u|ago_i }}</time>{% else %}<span class="mute">never</span>{% endif %}</td>
20 -
<td>{% if let Some(x) = t.expires_ms %}{{ x|when_i }}{% else %}<span class="mute">never</span>{% endif %}</td>
20 +
<td>{% if let Some(x) = t.expires_ms %}{{ x|when_i }}{% if t.renew_ms.is_some() %} <span class="mute small">renewed on use</span>{% endif %}{% else %}<span class="mute">never</span>{% endif %}</td>
21 21
<td><form class="tiny" method="post" action="/settings/tokens/{{ t.id }}/delete"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button>delete</button></form></td></tr>
22 22
{% endfor %}
23 23
</table></div>
…
14 unchanged lines