Skip to content

Commit 477385f

Browse files
authored
Add storage for Personal Access Tokens (#5106)
Defines a token format for PATs and implements the base storage functionality for PATs and 'Personal Sessions' which are conceptually the parent containers of PATs. Personal Sessions survive across regenerations of PATs and are the entities associated with the device ID. In virtually every way they are functionally the same as Compat or OAuth2 Sessions.
2 parents df2f611 + 277e8e8 commit 477385f

29 files changed

+2010
-1
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/data-model/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use thiserror::Error;
1111
pub mod clock;
1212
pub(crate) mod compat;
1313
pub mod oauth2;
14+
pub mod personal;
1415
pub(crate) mod policy_data;
1516
mod site_config;
1617
pub(crate) mod tokens;
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright 2025 New Vector Ltd.
2+
//
3+
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
4+
// Please see LICENSE files in the repository root for full details.
5+
6+
pub mod session;
7+
8+
use chrono::{DateTime, Utc};
9+
use ulid::Ulid;
10+
11+
#[derive(Debug, Clone, PartialEq, Eq)]
12+
pub struct PersonalAccessToken {
13+
pub id: Ulid,
14+
pub session_id: Ulid,
15+
pub created_at: DateTime<Utc>,
16+
pub expires_at: Option<DateTime<Utc>>,
17+
pub revoked_at: Option<DateTime<Utc>>,
18+
}
19+
20+
impl PersonalAccessToken {
21+
#[must_use]
22+
pub fn is_valid(&self, now: DateTime<Utc>) -> bool {
23+
if self.revoked_at.is_some() {
24+
return false;
25+
}
26+
if let Some(expires_at) = self.expires_at {
27+
expires_at > now
28+
} else {
29+
true
30+
}
31+
}
32+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// Copyright 2025 New Vector Ltd.
2+
//
3+
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
4+
// Please see LICENSE files in the repository root for full details.
5+
6+
use std::net::IpAddr;
7+
8+
use chrono::{DateTime, Utc};
9+
use oauth2_types::scope::Scope;
10+
use serde::Serialize;
11+
use ulid::Ulid;
12+
13+
use crate::{Client, InvalidTransitionError, User};
14+
15+
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
16+
pub enum SessionState {
17+
#[default]
18+
Valid,
19+
Revoked {
20+
revoked_at: DateTime<Utc>,
21+
},
22+
}
23+
24+
impl SessionState {
25+
/// Returns `true` if the session state is [`Valid`].
26+
///
27+
/// [`Valid`]: SessionState::Valid
28+
#[must_use]
29+
pub fn is_valid(&self) -> bool {
30+
matches!(self, Self::Valid)
31+
}
32+
33+
/// Returns `true` if the session state is [`Revoked`].
34+
///
35+
/// [`Revoked`]: SessionState::Revoked
36+
#[must_use]
37+
pub fn is_revoked(&self) -> bool {
38+
matches!(self, Self::Revoked { .. })
39+
}
40+
41+
/// Transitions the session state to [`Revoked`].
42+
///
43+
/// # Parameters
44+
///
45+
/// * `revoked_at` - The time at which the session was revoked.
46+
///
47+
/// # Errors
48+
///
49+
/// Returns an error if the session state is already [`Revoked`].
50+
///
51+
/// [`Revoked`]: SessionState::Revoked
52+
pub fn revoke(self, revoked_at: DateTime<Utc>) -> Result<Self, InvalidTransitionError> {
53+
match self {
54+
Self::Valid => Ok(Self::Revoked { revoked_at }),
55+
Self::Revoked { .. } => Err(InvalidTransitionError),
56+
}
57+
}
58+
59+
/// Returns the time the session was revoked, if any
60+
///
61+
/// Returns `None` if the session is still [`Valid`].
62+
///
63+
/// [`Valid`]: SessionState::Valid
64+
#[must_use]
65+
pub fn revoked_at(&self) -> Option<DateTime<Utc>> {
66+
match self {
67+
Self::Valid => None,
68+
Self::Revoked { revoked_at } => Some(*revoked_at),
69+
}
70+
}
71+
}
72+
73+
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
74+
pub struct PersonalSession {
75+
pub id: Ulid,
76+
pub state: SessionState,
77+
pub owner: PersonalSessionOwner,
78+
pub actor_user_id: Ulid,
79+
pub human_name: String,
80+
/// The scope for the session, identical to OAuth 2 sessions.
81+
/// May or may not include a device scope
82+
/// (personal sessions can be deviceless).
83+
pub scope: Scope,
84+
pub created_at: DateTime<Utc>,
85+
pub last_active_at: Option<DateTime<Utc>>,
86+
pub last_active_ip: Option<IpAddr>,
87+
}
88+
89+
#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize)]
90+
pub enum PersonalSessionOwner {
91+
/// The personal session is owned by the user with the given `user_id`.
92+
User(Ulid),
93+
/// The personal session is owned by the OAuth 2 Client with the given
94+
/// `oauth2_client_id`.
95+
OAuth2Client(Ulid),
96+
}
97+
98+
impl<'a> From<&'a User> for PersonalSessionOwner {
99+
fn from(value: &'a User) -> Self {
100+
PersonalSessionOwner::User(value.id)
101+
}
102+
}
103+
104+
impl<'a> From<&'a Client> for PersonalSessionOwner {
105+
fn from(value: &'a Client) -> Self {
106+
PersonalSessionOwner::OAuth2Client(value.id)
107+
}
108+
}
109+
110+
impl std::ops::Deref for PersonalSession {
111+
type Target = SessionState;
112+
113+
fn deref(&self) -> &Self::Target {
114+
&self.state
115+
}
116+
}
117+
118+
impl PersonalSession {
119+
/// Marks the session as revoked.
120+
///
121+
/// # Parameters
122+
///
123+
/// * `revoked_at` - The time at which the session was finished.
124+
///
125+
/// # Errors
126+
///
127+
/// Returns an error if the session is already finished.
128+
pub fn finish(mut self, revoked_at: DateTime<Utc>) -> Result<Self, InvalidTransitionError> {
129+
self.state = self.state.revoke(revoked_at)?;
130+
Ok(self)
131+
}
132+
}

crates/data-model/src/tokens.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,9 @@ pub enum TokenType {
240240

241241
/// A legacy refresh token
242242
CompatRefreshToken,
243+
244+
/// A personal access token.
245+
PersonalAccessToken,
243246
}
244247

245248
impl std::fmt::Display for TokenType {
@@ -249,6 +252,7 @@ impl std::fmt::Display for TokenType {
249252
TokenType::RefreshToken => write!(f, "refresh token"),
250253
TokenType::CompatAccessToken => write!(f, "compat access token"),
251254
TokenType::CompatRefreshToken => write!(f, "compat refresh token"),
255+
TokenType::PersonalAccessToken => write!(f, "personal access token"),
252256
}
253257
}
254258
}
@@ -260,6 +264,7 @@ impl TokenType {
260264
TokenType::RefreshToken => "mar",
261265
TokenType::CompatAccessToken => "mct",
262266
TokenType::CompatRefreshToken => "mcr",
267+
TokenType::PersonalAccessToken => "mpt",
263268
}
264269
}
265270

@@ -269,6 +274,7 @@ impl TokenType {
269274
"mar" => Some(TokenType::RefreshToken),
270275
"mct" | "syt" => Some(TokenType::CompatAccessToken),
271276
"mcr" | "syr" => Some(TokenType::CompatRefreshToken),
277+
"mpt" => Some(TokenType::PersonalAccessToken),
272278
_ => None,
273279
}
274280
}
@@ -335,7 +341,9 @@ impl PartialEq<OAuthTokenTypeHint> for TokenType {
335341
matches!(
336342
(self, other),
337343
(
338-
TokenType::AccessToken | TokenType::CompatAccessToken,
344+
TokenType::AccessToken
345+
| TokenType::CompatAccessToken
346+
| TokenType::PersonalAccessToken,
339347
OAuthTokenTypeHint::AccessToken
340348
) | (
341349
TokenType::RefreshToken | TokenType::CompatRefreshToken,

crates/handlers/src/activity_tracker/mod.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ static MESSAGE_QUEUE_SIZE: usize = 1000;
2424
enum SessionKind {
2525
OAuth2,
2626
Compat,
27+
/// Session associated with personal access tokens
28+
Personal,
2729
Browser,
2830
}
2931

@@ -32,6 +34,7 @@ impl SessionKind {
3234
match self {
3335
SessionKind::OAuth2 => "oauth2",
3436
SessionKind::Compat => "compat",
37+
SessionKind::Personal => "personal",
3538
SessionKind::Browser => "browser",
3639
}
3740
}
@@ -108,6 +111,28 @@ impl ActivityTracker {
108111
}
109112
}
110113

114+
/// Record activity in a personal access token session.
115+
pub async fn record_personal_access_token_session(
116+
&self,
117+
clock: &dyn Clock,
118+
session: &Session,
119+
ip: Option<IpAddr>,
120+
) {
121+
let res = self
122+
.channel
123+
.send(Message::Record {
124+
kind: SessionKind::Personal,
125+
id: session.id,
126+
date_time: clock.now(),
127+
ip,
128+
})
129+
.await;
130+
131+
if let Err(e) = res {
132+
tracing::error!("Failed to record Personal session: {}", e);
133+
}
134+
}
135+
111136
/// Record activity in a compat session.
112137
pub async fn record_compat_session(
113138
&self,

crates/handlers/src/activity_tracker/worker.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ impl Worker {
224224
let mut browser_sessions = Vec::new();
225225
let mut oauth2_sessions = Vec::new();
226226
let mut compat_sessions = Vec::new();
227+
let mut personal_sessions = Vec::new();
227228

228229
for ((kind, id), record) in pending_records {
229230
match kind {
@@ -236,6 +237,9 @@ impl Worker {
236237
SessionKind::Compat => {
237238
compat_sessions.push((*id, record.end_time, record.ip));
238239
}
240+
SessionKind::Personal => {
241+
personal_sessions.push((*id, record.end_time, record.ip));
242+
}
239243
}
240244
}
241245

@@ -253,6 +257,7 @@ impl Worker {
253257
repo.compat_session()
254258
.record_batch_activity(compat_sessions)
255259
.await?;
260+
// TODO: personal sessions: record
256261

257262
repo.save().await?;
258263
self.pending_records.clear();

crates/handlers/src/oauth2/introspection.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,11 @@ pub(crate) async fn post(
625625
device_id: session.device.map(Device::into),
626626
}
627627
}
628+
629+
TokenType::PersonalAccessToken => {
630+
// TODO
631+
return Err(RouteError::UnknownToken(TokenType::PersonalAccessToken));
632+
}
628633
};
629634

630635
repo.save().await?;

crates/storage-pg/.sqlx/query-06d67595eeef23d5f2773632e0956577d98074e244a35c0d3be24bc18d9d0daa.json

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/storage-pg/.sqlx/query-0e45995714e60b71e0f0158500a63aa46225245a04d1c7bc24b5275c44a6d58d.json

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)