Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/create-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/release-trigger.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand All @@ -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"]
17 changes: 17 additions & 0 deletions backend/entity/src/entities/invalid_jwt.rs
Original file line number Diff line number Diff line change
@@ -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 {}
1 change: 1 addition & 0 deletions backend/entity/src/entities/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions backend/entity/src/entities/prelude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions backend/migration/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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),
]
}
}
35 changes: 35 additions & 0 deletions backend/migration/src/m20260704_120000_recreate_invalid_jwt.rs
Original file line number Diff line number Diff line change
@@ -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,
}
6 changes: 3 additions & 3 deletions backend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -162,9 +162,9 @@ mod test {
let db = test_db().await;
let (_state, updater) = UpdateState::<UpdateMessage>::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;
}
Expand Down
11 changes: 11 additions & 0 deletions backend/src/oauth/jwt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use centaurus::{
request::extract::StateExtractExt,
},
bail,
db::{init::Connection, tables::ConnectionExt},
};
use http::request::Parts;
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -51,6 +52,16 @@ impl<S: Sync> FromRequestParts<S> for OAuthClaims {
bail!(UNAUTHORIZED, "invalid token");
};

let db = parts.extract_state::<Connection>().await;
if !db
.invalid_jwt()
.is_token_valid(&token)
.await
.unwrap_or(false)
{
bail!(UNAUTHORIZED, "revoked token");
}

Ok(claims)
}
}
Expand Down
5 changes: 4 additions & 1 deletion backend/src/oauth/mod.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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())))
}
110 changes: 106 additions & 4 deletions backend/src/oauth/token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -35,6 +39,28 @@ pub fn router() -> Router {
.route("/revoke", post(revoke))
}

#[derive(Clone)]
pub struct InvalidJwtCleanup {
_handle: Arc<JoinHandle<()>>,
}

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,
Expand Down Expand Up @@ -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 {}",
Expand Down Expand Up @@ -332,10 +369,11 @@ struct RevokeReq {
token: String,
}

#[instrument(skip(state))]
#[instrument(skip(state, db))]
async fn revoke(
Query(req_p): Query<RevokeReqOption>,
state: JwtStateOther,
db: Connection,
Form(req_b): Form<RevokeReqOption>,
) -> centaurus::error::Result<()> {
let req = if let Some(req) = req_p.try_into() {
Expand All @@ -346,7 +384,19 @@ async fn revoke(
bail!("invalid_request");
};

let _claims = state.validate_token::<OAuthClaims>(&req.token)?;
let exp = if let Ok(claims) = state.validate_token::<OAuthClaims>(&req.token) {
claims.exp
} else {
state.validate_token::<RefreshTokenClaims>(&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(())
}
Expand All @@ -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;
Expand Down Expand Up @@ -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]
Expand All @@ -949,6 +1050,7 @@ mod test {
let res = revoke(
Query(RevokeReqOption { token: None }),
c.jwt,
c.db,
Form(RevokeReqOption { token: None }),
)
.await;
Expand Down