From ca4b66ec3e85be224ba3b976a354bd0a299ec290 Mon Sep 17 00:00:00 2001 From: ProfiiDev <92174452+Profiidev@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:29:04 +0200 Subject: [PATCH 1/2] chore: run docker container as non root --- .github/workflows/create-release.yml | 4 +++- .github/workflows/release-trigger.yml | 4 +++- Dockerfile | 7 ++++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 6ba09d8d..49875c76 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -26,8 +26,10 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Create tag + env: + HEAD_REF: ${{ github.event.pull_request.head.ref }} run: | - VERSION=${{ github.event.pull_request.head.ref }} + VERSION=$HEAD_REF VERSION=${VERSION#chore/release-version-} echo "Creating tag for version $VERSION" git tag "$VERSION" diff --git a/.github/workflows/release-trigger.yml b/.github/workflows/release-trigger.yml index a8ae3a6c..78eba5b0 100644 --- a/.github/workflows/release-trigger.yml +++ b/.github/workflows/release-trigger.yml @@ -24,9 +24,11 @@ jobs: - name: Update version id: update_version + env: + INPUT_VERSION: ${{ github.event.inputs.version }} run: | JSON_FILE="app/src-tauri/tauri.conf.json" - RAW_TAG=${{ github.event.inputs.version }} + RAW_TAG=$INPUT_VERSION NEW_VERSION="${RAW_TAG#v}" echo "Bumping version to $NEW_VERSION" sed -i "s/^version = \".*\"/version = \"$NEW_VERSION\"/" backend/Cargo.toml diff --git a/Dockerfile b/Dockerfile index dc29e29d..b511928a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -85,7 +85,10 @@ ENV DB_URL="sqlite:/data/positron.db?mode=rwc" ENV STORAGE_PATH="/data/storage" ENV SITE_URL="http://localhost:8000" -RUN mkdir -p /data/storage +RUN mkdir -p /data/storage \ + && groupadd -r positron \ + && useradd -r -g positron positron \ + && chown -R positron:positron /data COPY --from=backend-builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ @@ -95,4 +98,6 @@ COPY --from=frontend-builder /app/frontend/package.json /app/frontend/package.js COPY --from=frontend-builder /app/package-lock.json /app/package-lock.json COPY --from=backend-builder /app/app /usr/local/bin/positron +USER positron + ENTRYPOINT ["positron", "serve"] From 1e898860df8043cc1b93efaa503280caa1a7f1c7 Mon Sep 17 00:00:00 2001 From: ProfiiDev <92174452+Profiidev@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:56:24 +0200 Subject: [PATCH 2/2] fix: oauth token revokation --- backend/entity/src/entities/invalid_jwt.rs | 17 +++ backend/entity/src/entities/mod.rs | 1 + backend/entity/src/entities/prelude.rs | 1 + backend/migration/src/lib.rs | 2 + .../m20260704_120000_recreate_invalid_jwt.rs | 35 ++++++ backend/src/lib.rs | 6 +- backend/src/oauth/jwt.rs | 11 ++ backend/src/oauth/mod.rs | 5 +- backend/src/oauth/token.rs | 110 +++++++++++++++++- 9 files changed, 180 insertions(+), 8 deletions(-) create mode 100644 backend/entity/src/entities/invalid_jwt.rs create mode 100644 backend/migration/src/m20260704_120000_recreate_invalid_jwt.rs diff --git a/backend/entity/src/entities/invalid_jwt.rs b/backend/entity/src/entities/invalid_jwt.rs new file mode 100644 index 00000000..b915f73e --- /dev/null +++ b/backend/entity/src/entities/invalid_jwt.rs @@ -0,0 +1,17 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "invalid_jwt")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub token: String, + pub exp: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend/entity/src/entities/mod.rs b/backend/entity/src/entities/mod.rs index 2c066f80..cf8a79f4 100644 --- a/backend/entity/src/entities/mod.rs +++ b/backend/entity/src/entities/mod.rs @@ -6,6 +6,7 @@ pub mod apod; pub mod group; pub mod group_permission; pub mod group_user; +pub mod invalid_jwt; pub mod key; pub mod note; pub mod note_snapshot; diff --git a/backend/entity/src/entities/prelude.rs b/backend/entity/src/entities/prelude.rs index 234f0773..4db93630 100644 --- a/backend/entity/src/entities/prelude.rs +++ b/backend/entity/src/entities/prelude.rs @@ -4,6 +4,7 @@ pub use super::apod::Entity as Apod; pub use super::group::Entity as Group; pub use super::group_permission::Entity as GroupPermission; pub use super::group_user::Entity as GroupUser; +pub use super::invalid_jwt::Entity as InvalidJwt; pub use super::key::Entity as Key; pub use super::note::Entity as Note; pub use super::note_snapshot::Entity as NoteSnapshot; diff --git a/backend/migration/src/lib.rs b/backend/migration/src/lib.rs index 72c709ba..ddb5c00d 100644 --- a/backend/migration/src/lib.rs +++ b/backend/migration/src/lib.rs @@ -16,6 +16,7 @@ mod m20260620_055816_note_snapshots; mod m20260622_123434_create_session_table; mod m20260622_145232_drop_invalid_jwt_table; mod m20260625_134247_note_last_updated; +mod m20260704_120000_recreate_invalid_jwt; pub struct Migrator; @@ -46,6 +47,7 @@ impl MigratorTrait for Migrator { Box::new(m20260622_123434_create_session_table::Migration), Box::new(m20260622_145232_drop_invalid_jwt_table::Migration), Box::new(m20260625_134247_note_last_updated::Migration), + Box::new(m20260704_120000_recreate_invalid_jwt::Migration), ] } } diff --git a/backend/migration/src/m20260704_120000_recreate_invalid_jwt.rs b/backend/migration/src/m20260704_120000_recreate_invalid_jwt.rs new file mode 100644 index 00000000..aa817317 --- /dev/null +++ b/backend/migration/src/m20260704_120000_recreate_invalid_jwt.rs @@ -0,0 +1,35 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(InvalidJwt::Table) + .if_not_exists() + .col(pk_uuid(InvalidJwt::Id)) + .col(string(InvalidJwt::Token)) + .col(date_time(InvalidJwt::Exp)) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(InvalidJwt::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum InvalidJwt { + Table, + Id, + Token, + Exp, +} diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 72db3400..1d81dd42 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -114,7 +114,7 @@ async fn state(mut router: ApiRouter, config: Config) -> ApiRouter { ); router = auth::state(router, &config, &db).await; router = mail::state(router, &db, &config).await; - router = oauth::state(router, &config).await; + router = oauth::state(router, &config, &db).await; router = services::state(router).await; router = well_known::state(router, &config).await; @@ -162,9 +162,9 @@ mod test { let db = test_db().await; let (_state, updater) = UpdateState::::init().await; router = notes::state(router, storage.clone(), updater, db.clone(), &config); - router = user::state(router, db); + router = user::state(router, db.clone()); router = services::state(router).await; - router = oauth::state(router, &config).await; + router = oauth::state(router, &config, &db).await; router = well_known::state(router, &config).await; let _ = router; } diff --git a/backend/src/oauth/jwt.rs b/backend/src/oauth/jwt.rs index 8049b818..e290a872 100644 --- a/backend/src/oauth/jwt.rs +++ b/backend/src/oauth/jwt.rs @@ -7,6 +7,7 @@ use centaurus::{ request::extract::StateExtractExt, }, bail, + db::{init::Connection, tables::ConnectionExt}, }; use http::request::Parts; use serde::{Deserialize, Serialize}; @@ -51,6 +52,16 @@ impl FromRequestParts for OAuthClaims { bail!(UNAUTHORIZED, "invalid token"); }; + let db = parts.extract_state::().await; + if !db + .invalid_jwt() + .is_token_valid(&token) + .await + .unwrap_or(false) + { + bail!(UNAUTHORIZED, "revoked token"); + } + Ok(claims) } } diff --git a/backend/src/oauth/mod.rs b/backend/src/oauth/mod.rs index a9e9b07b..4207b594 100644 --- a/backend/src/oauth/mod.rs +++ b/backend/src/oauth/mod.rs @@ -1,7 +1,9 @@ use aide::axum::ApiRouter; use axum::Extension; +use centaurus::db::init::Connection; pub use state::ConfigurationState; use state::{AuthorizeState, ClientState}; +use token::InvalidJwtCleanup; use crate::config::Config; @@ -24,9 +26,10 @@ pub fn router() -> ApiRouter { .merge(user::router()) } -pub async fn state(router: ApiRouter, config: &Config) -> ApiRouter { +pub async fn state(router: ApiRouter, config: &Config, db: &Connection) -> ApiRouter { router .layer(Extension(AuthorizeState::init(config))) .layer(Extension(ClientState::init(config))) .layer(Extension(ConfigurationState::init(config))) + .layer(Extension(InvalidJwtCleanup::init(db.clone()))) } diff --git a/backend/src/oauth/token.rs b/backend/src/oauth/token.rs index ef6dd076..c26c4a71 100644 --- a/backend/src/oauth/token.rs +++ b/backend/src/oauth/token.rs @@ -6,9 +6,13 @@ use centaurus::{ db::{init::Connection, tables::ConnectionExt}, serde::empty_string_as_none, }; -use chrono::{Duration, Utc}; +use chrono::{DateTime, Duration, Utc}; +use entity::invalid_jwt; +use sea_orm::{ActiveModelTrait, ActiveValue::Set}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use std::sync::Arc; +use tokio::{spawn, task::JoinHandle, time::sleep}; use tracing::instrument; use uuid::Uuid; @@ -35,6 +39,28 @@ pub fn router() -> Router { .route("/revoke", post(revoke)) } +#[derive(Clone)] +pub struct InvalidJwtCleanup { + _handle: Arc>, +} + +impl InvalidJwtCleanup { + pub fn init(db: Connection) -> Self { + let handle = spawn(async move { + loop { + if let Err(err) = db.invalid_jwt().remove_expired().await { + tracing::warn!(?err, "oauth token denylist cleanup failed"); + } + sleep(std::time::Duration::from_secs(600)).await; + } + }); + + Self { + _handle: Arc::new(handle), + } + } +} + #[derive(Serialize)] struct TokenRes { access_token: String, @@ -199,6 +225,17 @@ async fn refresh_token( Error::from_str("invalid_grant") })?; + // Reject revoked refresh tokens (fail closed on db error). + if !db + .invalid_jwt() + .is_token_valid(&body.refresh_token) + .await + .unwrap_or(false) + { + tracing::warn!("revoked refresh token for client: {}", client_id); + return Err(Error::from_str("invalid_grant")); + } + if claims.aud != client_id { tracing::warn!( "client id mismatch for refresh token: {}, expected {}, got {}", @@ -332,10 +369,11 @@ struct RevokeReq { token: String, } -#[instrument(skip(state))] +#[instrument(skip(state, db))] async fn revoke( Query(req_p): Query, state: JwtStateOther, + db: Connection, Form(req_b): Form, ) -> centaurus::error::Result<()> { let req = if let Some(req) = req_p.try_into() { @@ -346,7 +384,19 @@ async fn revoke( bail!("invalid_request"); }; - let _claims = state.validate_token::(&req.token)?; + let exp = if let Ok(claims) = state.validate_token::(&req.token) { + claims.exp + } else { + state.validate_token::(&req.token)?.exp + }; + let exp = DateTime::from_timestamp(exp, 0).unwrap_or_else(Utc::now); + + let model = invalid_jwt::ActiveModel { + token: Set(req.token), + exp: Set(exp.naive_utc()), + id: Set(Uuid::now_v7()), + }; + model.insert(&db.0).await?; Ok(()) } @@ -369,6 +419,8 @@ mod test { }; use axum::{Form, extract::Query}; use base64::{Engine, prelude::BASE64_URL_SAFE_NO_PAD}; + use centaurus::db::tables::ConnectionExt; + use chrono::{Duration, Utc}; use sha2::{Digest, Sha256}; use std::{collections::HashMap, time::Instant}; use uuid::Uuid; @@ -934,13 +986,62 @@ mod test { }; let tok = c.jwt.create_generic_token(&claims).unwrap(); + // token is accepted before revocation + assert!(c.db.invalid_jwt().is_token_valid(&tok).await.unwrap()); + revoke( Query(RevokeReqOption { token: None }), c.jwt, - Form(RevokeReqOption { token: Some(tok) }), + c.db.clone(), + Form(RevokeReqOption { + token: Some(tok.clone()), + }), ) .await .unwrap(); + + // after revocation the token is on the denylist + assert!(!c.db.invalid_jwt().is_token_valid(&tok).await.unwrap()); + } + + #[tokio::test] + async fn revoke_rejects_invalid_token() { + let c = ctx().await; + let res = revoke( + Query(RevokeReqOption { token: None }), + c.jwt, + c.db, + Form(RevokeReqOption { + token: Some("garbage".into()), + }), + ) + .await; + assert!(res.is_err()); + } + + #[tokio::test] + async fn refresh_token_rejects_revoked_token() { + let c = ctx().await; + let claims = refresh_claims(c.client_id, c.user, c.config.issuer.to_string()); + let rt = c.jwt.create_generic_token(&claims).unwrap(); + + // denylist the refresh token + c.db + .invalid_jwt() + .invalidate_jwt( + rt.clone(), + Utc::now() + Duration::seconds(3600), + std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0)), + ) + .await + .unwrap(); + + let body = TokenRefreshReq { refresh_token: rt }; + let err = refresh_token(c.jwt, c.db, c.config, body, c.client_id) + .await + .map(|_| ()) + .unwrap_err(); + assert_eq!(err_code(err), "invalid_grant"); } #[tokio::test] @@ -949,6 +1050,7 @@ mod test { let res = revoke( Query(RevokeReqOption { token: None }), c.jwt, + c.db, Form(RevokeReqOption { token: None }), ) .await;