From 00bd58ac9d64703b972a60d0c1c282ff1a0d9f52 Mon Sep 17 00:00:00 2001 From: rays Date: Mon, 5 Oct 2026 00:15:17 +0000 Subject: [PATCH] API tokens a person makes for scripts and agents to act as them (#123) The API took a session cookie, a proxy's word or the shared admin token, so a script or an agent working for one person had to sign in with their password and carry the cookie, or be given the admin token. Settings now makes named tokens, ipx_ and 256 random bits, sent as Authorization: Bearer. A token is its owner and no more. Only its SHA-256 is kept, in the new api_tokens table, with when it was made and last used; it is shown once and revoked from the same list. An unknown or revoked one gets a 401 rather than falling through to a cookie. Cloudflare Access still stands in front of the tunnel, so from outside a token needs an Access service token beside it; docs/sso.md says how. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + Cargo.lock | 1 + Cargo.toml | 1 + docs/sso.md | 14 +++++++++ src/auth.rs | 28 +++++++++++++++++ src/db.rs | 54 +++++++++++++++++++++++++++++++- src/entity.rs | 23 ++++++++++++++ src/web.rs | 74 ++++++++++++++++++++++++++++++++++++++++++-- tests/ui/app.spec.js | 27 ++++++++++++++++ web/app.css | 3 ++ web/src/dialogs.ts | 36 +++++++++++++++++++++ 11 files changed, 259 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5599b14..8079ce4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The Directory browses as Apple's does: a row of categories, and once one is picked, a row of its subcategories (Tech News under News, Video Games under Leisure), instead of one row mixing both. - A pinned item in a list wears the same disc as a pinned feed, in the theme's accent, instead of a plain solid pin. - The Directory can be sorted by name, A to Z or Z to A, or by most subscribers. It still opens A to Z. +- API tokens: in Settings, make a named token that lets a script or an agent use iPX as you, sent as `Authorization: Bearer`, and revoke it there. Each shows when it was last used. - An item published without a title shows its opening words, in plain text rather than bold, instead of "(untitled)"; one with no text either shows its file's name, or its show and date. Opened, it starts with its text. - A feed that has moved for good (a permanent redirect) is followed to its new address, which iPX then reads from, and says so in the log as `feed_moved`. A temporary redirect changes nothing. - The daemon sleeps until the next feed is due, at most ten minutes, instead of looking every minute; refreshing or adding a feed still wakes it at once. diff --git a/Cargo.lock b/Cargo.lock index 3a9340d..1c81711 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1843,6 +1843,7 @@ dependencies = [ "sea-orm", "serde", "serde_json", + "sha2 0.10.9", "tokio", "toml", "tower", diff --git a/Cargo.toml b/Cargo.toml index 93faf69..2f6fd15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ rss = "2.1.1" sea-orm = { version = "2.0.3", default-features = false, features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-rustls", "macros", "with-json", "sqlite-use-returning-for-3_35"] } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" +sha2 = "0.10.9" tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "sync", "time", "signal"] } toml = "1.1.5" tower = { version = "0.5.3", features = ["util"] } diff --git a/docs/sso.md b/docs/sso.md index bbafce7..210702c 100644 --- a/docs/sso.md +++ b/docs/sso.md @@ -233,5 +233,19 @@ Set `auto_create_users = false` once everyone who should have an account has one proxy vouching for an unknown name is logged and refused. Make people ahead of time instead, with the exact name the header will carry. +## API tokens for scripts and agents + +Anyone can make API tokens for their own account in Settings. A token is sent as +`Authorization: Bearer ipx_...` and acts as the person who made it, admin only if they are. Only +its SHA-256 is kept; it is shown once, when made, and revoked there too. + +Through the tunnel, Cloudflare Access turns a request with no Access sign-in towards Authentik +before ipx sees it, so a token alone does not get in that way. On the LAN, `http://192.168.1.130:8099` +takes it directly. From outside, make an Access service token, add a Service Auth policy for it to +the `ipodderx` application, and send `CF-Access-Client-Id` and `CF-Access-Client-Secret` beside the +`Authorization` header: Access lets the request through, vouches for no name, and ipx takes the +API token as who is asking. Do not bypass Access for `/api/*` instead; the token would then be the +only thing between the internet and the API. + See also [users.md](users.md) for what several people share, [configuration.md](configuration.md) for every `[web]` key, and [cli.md](cli.md) for the `ipx user` commands. diff --git a/src/auth.rs b/src/auth.rs index 5b2d72a..8922197 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -40,6 +40,19 @@ pub fn new_session_token() -> String { bytes.iter().map(|b| format!("{b:02x}")).collect() } +/// A new API token: `ipx_` and a session's 256 random bits, the prefix so one found in a log +/// or a file says what it opens (#123). +pub fn new_api_token() -> String { + format!("ipx_{}", new_session_token()) +} + +/// What is kept of an API token. A fast hash is enough: the token is 256 random bits, not a +/// password, so there is nothing to guess from a stolen hash. +pub fn api_token_hash(token: &str) -> String { + use sha2::Digest; + sha2::Sha256::digest(token.as_bytes()).iter().map(|b| format!("{b:02x}")).collect() +} + fn getrandom(buf: &mut [u8]) -> std::io::Result<()> { use std::io::Read; std::fs::File::open("/dev/urandom")?.read_exact(buf) @@ -74,6 +87,21 @@ mod tests { assert!(hash_password("short").is_err()); } + #[test] + fn api_tokens_are_prefixed_and_hash_to_hex() { + let t = new_api_token(); + assert!(t.starts_with("ipx_") && t.len() == 68); + let h = api_token_hash(&t); + assert_eq!(h.len(), 64); + assert_eq!(h, api_token_hash(&t), "the same token, the same hash"); + assert_ne!(h, api_token_hash(&new_api_token())); + assert_eq!( + api_token_hash("abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "SHA-256" + ); + } + #[test] fn session_tokens_are_long_and_distinct() { let a = new_session_token(); diff --git a/src/db.rs b/src/db.rs index 20a24c4..389a3d5 100644 --- a/src/db.rs +++ b/src/db.rs @@ -2,7 +2,7 @@ //! per-feed .ipxd plists, history.dat and qmcache.dat. use anyhow::{Context, Result}; -use crate::entity::{blocklists, catalogue, enclosures, entries, feeds, hidden, sessions, settings, subscriptions, users}; +use crate::entity::{api_tokens, blocklists, catalogue, enclosures, entries, feeds, hidden, sessions, settings, subscriptions, users}; use sea_orm::sea_query::{Expr, Func}; use sea_orm::{ ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set, @@ -59,6 +59,7 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> { schema.create_table_from_entity(settings::Entity), schema.create_table_from_entity(blocklists::Entity), schema.create_table_from_entity(hidden::Entity), + schema.create_table_from_entity(api_tokens::Entity), ] { orm.execute(table.if_not_exists()).await.context("creating the schema")?; } @@ -1685,6 +1686,7 @@ impl Db { /// The foreign key would take them anyway; this does not rely on it being switched on. pub async fn delete_user(&self, id: i64) -> Result<()> { sessions::Entity::delete_many().filter(sessions::Column::UserId.eq(id)).exec(&self.orm).await?; + api_tokens::Entity::delete_many().filter(api_tokens::Column::UserId.eq(id)).exec(&self.orm).await?; users::Entity::delete_by_id(id).exec(&self.orm).await?; Ok(()) } @@ -1737,6 +1739,56 @@ impl Db { Ok(found.map(User::from)) } + /// Keeps a new API token's hash under its owner; the token itself is shown once and not kept. + pub async fn create_api_token(&self, user_id: i64, name: &str, hash: &str) -> Result { + let made = api_tokens::ActiveModel { + user_id: Set(user_id), + name: Set(name.to_owned()), + hash: Set(hash.to_owned()), + created: Set(now()), + last_used: Set(None), + ..Default::default() + } + .insert(&self.orm) + .await?; + Ok(made.id) + } + + /// One person's tokens, newest first: names and dates, as the tokens themselves are not kept. + pub async fn api_tokens(&self, user_id: i64) -> Result> { + Ok(api_tokens::Entity::find() + .filter(api_tokens::Column::UserId.eq(user_id)) + .order_by_desc(api_tokens::Column::Id) + .all(&self.orm) + .await?) + } + + /// Revokes one of this person's tokens; someone else's id does nothing. Whether it was theirs. + pub async fn delete_api_token(&self, user_id: i64, id: i64) -> Result { + let gone = api_tokens::Entity::delete_many() + .filter(api_tokens::Column::Id.eq(id)) + .filter(api_tokens::Column::UserId.eq(user_id)) + .exec(&self.orm) + .await?; + Ok(gone.rows_affected > 0) + } + + /// The person an API token acts as, noting when it was used, as a session notes `seen`. + pub async fn api_token_user(&self, hash: &str) -> Result> { + let found = api_tokens::Entity::find() + .filter(api_tokens::Column::Hash.eq(hash)) + .find_also_related(users::Entity) + .one(&self.orm) + .await?; + let Some((token, Some(user))) = found else { return Ok(None) }; + api_tokens::Entity::update_many() + .col_expr(api_tokens::Column::LastUsed, Expr::val(now()).into()) + .filter(api_tokens::Column::Id.eq(token.id)) + .exec(&self.orm) + .await?; + Ok(Some(User::from(user))) + } + pub async fn delete_session(&self, token: &str) -> Result<()> { sessions::Entity::delete_by_id(token.to_owned()).exec(&self.orm).await?; Ok(()) diff --git a/src/entity.rs b/src/entity.rs index 6ecfdf7..173502a 100644 --- a/src/entity.rs +++ b/src/entity.rs @@ -287,6 +287,29 @@ pub mod sessions { owned_by_user!(); } +/// A token a script or agent sends as `Authorization: Bearer`, acting as the person who made +/// it (#123). Kept as its SHA-256, so the table is no use to anyone who reads it. +pub mod api_tokens { + use sea_orm::entity::prelude::*; + + #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] + #[sea_orm(table_name = "api_tokens")] + pub struct Model { + #[sea_orm(primary_key)] + pub id: i64, + pub user_id: i64, + /// What its owner called it, to tell one from another when revoking. + #[sea_orm(column_type = "Text")] + pub name: String, + #[sea_orm(unique, column_type = "Text")] + pub hash: String, + pub created: i64, + pub last_used: Option, + } + + owned_by_user!(); +} + /// The catalogue: every feed configured, with its shared settings as `config::Feed` in JSON, so a /// new setting on a feed needs no new column. It was config.toml's `[feeds]` (issue #18). pub mod catalogue { diff --git a/src/web.rs b/src/web.rs index c008dbb..851cb61 100644 --- a/src/web.rs +++ b/src/web.rs @@ -38,6 +38,8 @@ pub fn router(state: WebState) -> Router { Router::new() .route("/", get(index)) .route("/api/me", get(me).patch(patch_me)) + .route("/api/tokens", get(list_tokens).post(add_token)) + .route("/api/tokens/{id}", axum::routing::delete(remove_token)) .route("/api/logout", post(logout)) .route("/api/feeds", get(feeds).post(add_feed)) .route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed)) @@ -100,8 +102,8 @@ pub async fn serve(state: WebState, bind: &str) -> Result<()> { .context("serving the web ui") } -/// Who is asking, in order of how specific the claim is: a proxy that vouches for a name, -/// a session cookie, then the shared token (which is the admin). +/// Who is asking, in order of how specific the claim is: a proxy that vouches for a name, an +/// API token, a session cookie, then the shared token (which is the admin). /// /// Any of them has to survive being put in a cookie: an `