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 <noreply@anthropic.com>
This commit is contained in:
74
src/web.rs
74
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 `<audio src>` request is issued by
|
||||
/// the browser, and there is no way to attach a header to it.
|
||||
@@ -144,6 +146,26 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
|
||||
}
|
||||
let by_proxy = user.is_some();
|
||||
|
||||
// An API token, made by its owner in Settings, for a script or an agent acting as them
|
||||
// (#123). One that is not known is turned away rather than tried as anything else: a
|
||||
// script sent with a revoked token should hear so, not fall through to a cookie.
|
||||
if user.is_none()
|
||||
&& let Some(bearer) = req
|
||||
.headers()
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
{
|
||||
match state.ctx.db.api_token_user(&crate::auth::api_token_hash(bearer.trim())).await {
|
||||
Ok(Some(u)) => user = Some(u),
|
||||
Ok(None) => return (StatusCode::UNAUTHORIZED, "unknown or revoked API token").into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "looking up an API token");
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. A session cookie from signing in here.
|
||||
if user.is_none() {
|
||||
if let Some(sid) = cookie(&req, SESSION_COOKIE) {
|
||||
@@ -392,6 +414,54 @@ fn last_admin(users: &[crate::db::User], id: i64) -> bool {
|
||||
admins == [id]
|
||||
}
|
||||
|
||||
/// Your API tokens: names and dates, never the tokens, which are not kept.
|
||||
async fn list_tokens(
|
||||
State(state): State<WebState>,
|
||||
user: crate::db::User,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let tokens: Vec<_> = state
|
||||
.ctx
|
||||
.db
|
||||
.api_tokens(user.id).await?
|
||||
.iter()
|
||||
.map(|t| serde_json::json!({ "id": t.id, "name": t.name, "created": t.created, "last_used": t.last_used }))
|
||||
.collect();
|
||||
Ok(Json(serde_json::json!(tokens)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NewToken {
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// Makes an API token that acts as you, and answers with it: the only time it is shown.
|
||||
async fn add_token(
|
||||
State(state): State<WebState>,
|
||||
user: crate::db::User,
|
||||
Json(body): Json<NewToken>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let name = body.name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(ApiError::bad_request("give the token a name, to know it by when revoking it"));
|
||||
}
|
||||
let token = crate::auth::new_api_token();
|
||||
let id = state.ctx.db.create_api_token(user.id, name, &crate::auth::api_token_hash(&token)).await?;
|
||||
tracing::info!(user = %user.name, token = name, "API token made");
|
||||
Ok(Json(serde_json::json!({ "id": id, "name": name, "token": token })))
|
||||
}
|
||||
|
||||
async fn remove_token(
|
||||
State(state): State<WebState>,
|
||||
user: crate::db::User,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
if !state.ctx.db.delete_api_token(user.id, id).await? {
|
||||
return Err(ApiError::bad_request(format!("you have no API token with id {id}")));
|
||||
}
|
||||
tracing::info!(user = %user.name, id, "API token revoked");
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn list_users(
|
||||
State(state): State<WebState>,
|
||||
user: crate::db::User,
|
||||
|
||||
Reference in New Issue
Block a user