• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

divviup / divviup-api / 25222136258

01 May 2026 04:15PM UTC coverage: 57.824% (+0.6%) from 57.194%
25222136258

push

github

web-flow
Migrate from Trillium [part 6]: auth routes (#2234)

* Migrate from Trillium [part 6]: auth routes

Move `/login`, `/logout`, and `/callback` from the Trillium router to
Axum handlers. I've pulled in `tower-sessions` 0.15 with the
existing `divviup.sid` cookie name, backed by the same `session`
database table via the `TowerSessionStore` added in part 3.

- `oauth2::redirect` / `oauth2::callback` / `misc::logout` rewritten as
  Axum handlers; the Trillium versions are removed.
- `User` gains `FromRequestParts` and `OptionalFromRequestParts` impls;
  `from_parts` now returns `Option<User>` and the `PermissionsActor`
  extractor is updated to match.
- `Error` gains `CallbackCsrfMismatch`, `CallbackMissingPkce`, and
  `CallbackMissingCode` variants, all mapped to 403 to match the
  previous Trillium behavior.
- `OauthClient` is added to `AxumAppState` via `FromRef`.
- `AxumProxy` disables reqwest's default redirect-following so that 302
  responses from Axum handlers (e.g. `/login` redirecting to Auth0) are
  passed back to the caller instead of followed by the proxy.
- When `debug_assertions` is enabled, it compiles in an Axum middleware that reads an
  `X-Integration-Testing-User` header and injects the decoded `User`
  into request extensions, letting integration tests simulate a
  logged-in user through the proxy path. `TestExt::with_user(&user)`
  replaces `with_state(user)` for routes that have migrated.
- The logout test no longer asserts `is_destroyed()`; the cookie
  clearing is tower-sessions's responsibility and will be covered
  end-to-end in part 8 when Trillium is removed.

95 of 136 new or added lines in 9 files covered. (69.85%)

8 existing lines in 2 files now uncovered.

4412 of 7630 relevant lines covered (57.82%)

60.48 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

45.6
/src/handler/session_store.rs
1
use crate::{config::SessionSecrets, entity::session, Db};
2
use async_session::{
3
    async_trait,
4
    chrono::{DateTime, Utc},
5
    Session,
6
};
7
use sea_orm::{
8
    sea_query::{any, OnConflict},
9
    ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter,
10
};
11
use serde_json::json;
12
use std::collections::HashMap;
13
use time::{Duration, OffsetDateTime};
14
use tower_sessions::{
15
    cookie::{Key, SameSite},
16
    service::SignedCookie,
17
    session::{Id as TowerSessionId, Record},
18
    session_store as tower_store, Expiry, SessionManagerLayer,
19
};
20

21
/// Cookie name shared with the Trillium-side session middleware. Both sides
22
/// set a cookie with this name; the wire formats are incompatible, so each
23
/// side only successfully parses its own.
24
// TODO: remove this comment when Trillium is removed — the incompatibility
25
// note will no longer apply.
26
pub const SESSION_COOKIE_NAME: &str = "divviup.sid";
27

28
#[derive(Debug, Clone)]
29
pub struct SessionStore {
30
    db: Db,
31
}
32

33
impl SessionStore {
34
    pub fn new(db: Db) -> Self {
278✔
35
        Self { db }
278✔
36
    }
278✔
37
}
38

39
impl TryFrom<&Session> for session::Model {
40
    type Error = serde_json::Error;
41

42
    fn try_from(session: &Session) -> Result<Self, Self::Error> {
273✔
43
        Ok(Self {
44
            id: session.id().to_string(),
273✔
45
            // unwrap safety: session object comes from the session handler, and its timestamp
46
            // we made ourselves.
47
            expiry: session
273✔
48
                .expiry()
273✔
49
                .map(|e| OffsetDateTime::from_unix_timestamp(e.timestamp()).unwrap()),
273✔
50
            // unwrap safety: if the serialization is successful, the data element
51
            // will be there.
52
            data: serde_json::from_value(
273✔
53
                serde_json::to_value(session)?.get("data").unwrap().clone(),
273✔
54
            )?,
×
55
        })
56
    }
273✔
57
}
58

59
impl TryFrom<session::Model> for Session {
60
    type Error = serde_json::Error;
61
    fn try_from(db_session: session::Model) -> Result<Session, serde_json::Error> {
×
62
        let mut session: Session = serde_json::from_value(json!({
×
63
            "id": db_session.id,
×
64
            "data": db_session.data,
×
65
        }))?;
×
66
        if let Some(x) = db_session.expiry {
×
67
            // unwrap safety: the expiry time from the database is a timestamp we made
×
68
            // ourselves.
×
69
            session.set_expiry(
×
70
                DateTime::<Utc>::from_timestamp(x.unix_timestamp(), x.nanosecond()).unwrap(),
×
71
            );
×
72
        }
×
73
        Ok(session)
×
74
    }
×
75
}
76

77
#[async_trait]
78
impl async_session::SessionStore for SessionStore {
79
    async fn load_session(&self, cookie_value: String) -> async_session::Result<Option<Session>> {
×
80
        let id = Session::id_from_cookie_value(&cookie_value)?;
×
81
        Ok(session::Entity::find_by_id(id)
×
82
            .filter(any![
×
83
                session::Column::Expiry.is_null(),
×
84
                session::Column::Expiry.gt(OffsetDateTime::now_utc())
×
85
            ])
×
86
            .one(&self.db)
×
87
            .await?
×
88
            .map(Session::try_from)
×
89
            .transpose()?)
×
90
    }
×
91

92
    async fn store_session(&self, session: Session) -> async_session::Result<Option<String>> {
546✔
93
        let session_model = session::Model::try_from(&session)?.into_active_model();
273✔
94

95
        session::Entity::insert(session_model)
273✔
96
            .on_conflict(
273✔
97
                OnConflict::column(session::Column::Id)
273✔
98
                    .update_columns([session::Column::Data, session::Column::Expiry])
273✔
99
                    .clone(),
273✔
100
            )
273✔
101
            .exec(&self.db)
273✔
102
            .await?;
273✔
103

104
        Ok(session.into_cookie_value())
273✔
105
    }
546✔
106

UNCOV
107
    async fn destroy_session(&self, session: Session) -> async_session::Result {
×
UNCOV
108
        session::Entity::delete_by_id(session.id())
×
UNCOV
109
            .exec(&self.db)
×
UNCOV
110
            .await?;
×
UNCOV
111
        Ok(())
×
UNCOV
112
    }
×
113

114
    async fn clear_store(&self) -> async_session::Result {
×
115
        session::Entity::delete_many().exec(&self.db).await?;
×
116
        Ok(())
×
117
    }
×
118
}
119

120
/// Axum-side session store implementing [`tower_sessions_core::SessionStore`].
121
///
122
/// Uses the same `session` database table as the Trillium-side [`SessionStore`],
123
/// but with a different session ID format (`tower-sessions` uses base64-encoded
124
/// `i128` rather than `async_session`'s UUID strings).
125
// TODO: remove the Trillium-side `SessionStore` and the `async_session`
126
// dependency when Trillium is removed.
127
#[derive(Debug, Clone)]
128
pub struct TowerSessionStore {
129
    db: Db,
130
}
131

132
impl TowerSessionStore {
133
    pub fn new(db: Db) -> Self {
278✔
134
        Self { db }
278✔
135
    }
278✔
136
}
137

138
/// Build the Axum-side [`SessionManagerLayer`].
139
///
140
/// `tower-sessions` 0.15 accepts only a single signing key; there is no
141
/// `with_older_secrets` equivalent. Key rotation for the Axum cookie will be
142
/// revisited once the Trillium server is removed in Part 8.
143
pub fn axum_session_layer(
278✔
144
    db: Db,
278✔
145
    secrets: &SessionSecrets,
278✔
146
) -> SessionManagerLayer<TowerSessionStore, SignedCookie> {
278✔
147
    // `cookie::Key::from` panics on keys shorter than 64 bytes. We only
148
    // guarantee 32, so derive a longer key from the configured secret.
149
    let key = Key::derive_from(&secrets.current);
278✔
150
    SessionManagerLayer::new(TowerSessionStore::new(db))
278✔
151
        .with_name(SESSION_COOKIE_NAME)
278✔
152
        .with_secure(true)
278✔
153
        .with_http_only(true)
278✔
154
        .with_same_site(SameSite::Lax)
278✔
155
        .with_path("/")
278✔
156
        .with_signed(key)
278✔
157
        .with_expiry(Expiry::OnInactivity(Duration::days(1)))
278✔
158
}
278✔
159

160
#[async_trait]
161
impl tower_store::SessionStore for TowerSessionStore {
162
    async fn save(&self, record: &Record) -> tower_store::Result<()> {
2✔
163
        let model = session::Model {
1✔
164
            id: record.id.to_string(),
1✔
165
            expiry: Some(record.expiry_date),
1✔
166
            data: serde_json::to_value(&record.data)
1✔
167
                .map_err(|e| tower_store::Error::Encode(e.to_string()))?,
1✔
168
        };
169

170
        session::Entity::insert(model.into_active_model())
1✔
171
            .on_conflict(
1✔
172
                OnConflict::column(session::Column::Id)
1✔
173
                    .update_columns([session::Column::Data, session::Column::Expiry])
1✔
174
                    .clone(),
1✔
175
            )
1✔
176
            .exec(&self.db)
1✔
177
            .await
1✔
178
            .map_err(|e| tower_store::Error::Backend(e.to_string()))?;
1✔
179

180
        Ok(())
1✔
181
    }
2✔
182

183
    async fn load(&self, session_id: &TowerSessionId) -> tower_store::Result<Option<Record>> {
×
184
        let model = session::Entity::find_by_id(session_id.to_string())
×
185
            .filter(any![
×
186
                session::Column::Expiry.is_null(),
×
187
                session::Column::Expiry.gt(OffsetDateTime::now_utc())
×
188
            ])
×
189
            .one(&self.db)
×
190
            .await
×
191
            .map_err(|e| tower_store::Error::Backend(e.to_string()))?;
×
192

193
        model
×
194
            .map(|m| {
×
195
                let data: HashMap<String, serde_json::Value> = serde_json::from_value(m.data)
×
196
                    .map_err(|e| tower_store::Error::Decode(e.to_string()))?;
×
197
                let id: TowerSessionId = m.id.parse().map_err(|e: base64::DecodeSliceError| {
×
198
                    tower_store::Error::Decode(e.to_string())
×
199
                })?;
×
200
                Ok(Record {
201
                    id,
×
202
                    data,
×
203
                    expiry_date: m.expiry.unwrap_or_else(|| {
×
204
                        // The DB allows null expiry, but tower-sessions requires a
205
                        // value. Use a far-future sentinel.
206
                        OffsetDateTime::now_utc() + time::Duration::weeks(52)
×
207
                    }),
×
208
                })
209
            })
×
210
            .transpose()
×
211
    }
×
212

213
    async fn delete(&self, session_id: &TowerSessionId) -> tower_store::Result<()> {
×
214
        session::Entity::delete_by_id(session_id.to_string())
×
215
            .exec(&self.db)
×
216
            .await
×
217
            .map_err(|e| tower_store::Error::Backend(e.to_string()))?;
×
218
        Ok(())
×
219
    }
×
220
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc