Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/data-model/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use thiserror::Error;
pub mod clock;
pub(crate) mod compat;
pub mod oauth2;
pub mod personal;
pub(crate) mod policy_data;
mod site_config;
pub(crate) mod tokens;
Expand Down
32 changes: 32 additions & 0 deletions crates/data-model/src/personal/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.

pub mod session;

use chrono::{DateTime, Utc};
use ulid::Ulid;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PersonalAccessToken {
pub id: Ulid,
pub session_id: Ulid,
pub created_at: DateTime<Utc>,
pub expires_at: Option<DateTime<Utc>>,
pub revoked_at: Option<DateTime<Utc>>,
}

impl PersonalAccessToken {
#[must_use]
pub fn is_valid(&self, now: DateTime<Utc>) -> bool {
if self.revoked_at.is_some() {
return false;
}
if let Some(expires_at) = self.expires_at {
expires_at > now
} else {
true
}
}
}
132 changes: 132 additions & 0 deletions crates/data-model/src/personal/session.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.

use std::net::IpAddr;

use chrono::{DateTime, Utc};
use oauth2_types::scope::Scope;
use serde::Serialize;
use ulid::Ulid;

use crate::{Client, InvalidTransitionError, User};

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub enum SessionState {
#[default]
Valid,
Revoked {
revoked_at: DateTime<Utc>,
},
}

impl SessionState {
/// Returns `true` if the session state is [`Valid`].
///
/// [`Valid`]: SessionState::Valid
#[must_use]
pub fn is_valid(&self) -> bool {
matches!(self, Self::Valid)
}

/// Returns `true` if the session state is [`Revoked`].
///
/// [`Revoked`]: SessionState::Revoked
#[must_use]
pub fn is_revoked(&self) -> bool {
matches!(self, Self::Revoked { .. })
}

/// Transitions the session state to [`Revoked`].
///
/// # Parameters
///
/// * `revoked_at` - The time at which the session was revoked.
///
/// # Errors
///
/// Returns an error if the session state is already [`Revoked`].
///
/// [`Revoked`]: SessionState::Revoked
pub fn revoke(self, revoked_at: DateTime<Utc>) -> Result<Self, InvalidTransitionError> {
match self {
Self::Valid => Ok(Self::Revoked { revoked_at }),
Self::Revoked { .. } => Err(InvalidTransitionError),
}
}

/// Returns the time the session was revoked, if any
///
/// Returns `None` if the session is still [`Valid`].
///
/// [`Valid`]: SessionState::Valid
#[must_use]
pub fn revoked_at(&self) -> Option<DateTime<Utc>> {
match self {
Self::Valid => None,
Self::Revoked { revoked_at } => Some(*revoked_at),
}
}
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PersonalSession {
pub id: Ulid,
pub state: SessionState,
pub owner: PersonalSessionOwner,
pub actor_user_id: Ulid,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm now thinking that potentially you could have no actor (like you don't need one when talking to the MAS admin API) but then the main reason for you to use PATs (and not just the client credentials grant) is if you need to access Synapse and MAS, so probably not worth supporting

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also thought the same really. Seems like extra kerfuffle for no apparent benefit; could be relaxed later if it turns out to be useful though

pub human_name: String,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if this should be optional… but I guess most tools force you to have a name

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was my thought really; other tools make you put in a name and it seems self-sabotaging to not have a label for your PATs, so it seems like a good idea to just have one for simplicity.

/// The scope for the session, identical to OAuth 2 sessions.
/// May or may not include a device scope
/// (personal sessions can be deviceless).
pub scope: Scope,
pub created_at: DateTime<Utc>,
pub last_active_at: Option<DateTime<Utc>>,
pub last_active_ip: Option<IpAddr>,
}

#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize)]
pub enum PersonalSessionOwner {
/// The personal session is owned by the user with the given `user_id`.
User(Ulid),
/// The personal session is owned by the OAuth 2 Client with the given
/// `oauth2_client_id`.
OAuth2Client(Ulid),
}

impl<'a> From<&'a User> for PersonalSessionOwner {
fn from(value: &'a User) -> Self {
PersonalSessionOwner::User(value.id)
}
}

impl<'a> From<&'a Client> for PersonalSessionOwner {
fn from(value: &'a Client) -> Self {
PersonalSessionOwner::OAuth2Client(value.id)
}
}

impl std::ops::Deref for PersonalSession {
type Target = SessionState;

fn deref(&self) -> &Self::Target {
&self.state
}
}

impl PersonalSession {
/// Marks the session as revoked.
///
/// # Parameters
///
/// * `revoked_at` - The time at which the session was finished.
///
/// # Errors
///
/// Returns an error if the session is already finished.
pub fn finish(mut self, revoked_at: DateTime<Utc>) -> Result<Self, InvalidTransitionError> {
self.state = self.state.revoke(revoked_at)?;
Ok(self)
}
}
10 changes: 9 additions & 1 deletion crates/data-model/src/tokens.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,9 @@ pub enum TokenType {

/// A legacy refresh token
CompatRefreshToken,

/// A personal access token.
PersonalAccessToken,
}

impl std::fmt::Display for TokenType {
Expand All @@ -249,6 +252,7 @@ impl std::fmt::Display for TokenType {
TokenType::RefreshToken => write!(f, "refresh token"),
TokenType::CompatAccessToken => write!(f, "compat access token"),
TokenType::CompatRefreshToken => write!(f, "compat refresh token"),
TokenType::PersonalAccessToken => write!(f, "personal access token"),
}
}
}
Expand All @@ -260,6 +264,7 @@ impl TokenType {
TokenType::RefreshToken => "mar",
TokenType::CompatAccessToken => "mct",
TokenType::CompatRefreshToken => "mcr",
TokenType::PersonalAccessToken => "mpt",
}
}

Expand All @@ -269,6 +274,7 @@ impl TokenType {
"mar" => Some(TokenType::RefreshToken),
"mct" | "syt" => Some(TokenType::CompatAccessToken),
"mcr" | "syr" => Some(TokenType::CompatRefreshToken),
"mpt" => Some(TokenType::PersonalAccessToken),
_ => None,
}
}
Expand Down Expand Up @@ -335,7 +341,9 @@ impl PartialEq<OAuthTokenTypeHint> for TokenType {
matches!(
(self, other),
(
TokenType::AccessToken | TokenType::CompatAccessToken,
TokenType::AccessToken
| TokenType::CompatAccessToken
| TokenType::PersonalAccessToken,
OAuthTokenTypeHint::AccessToken
) | (
TokenType::RefreshToken | TokenType::CompatRefreshToken,
Expand Down
25 changes: 25 additions & 0 deletions crates/handlers/src/activity_tracker/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ static MESSAGE_QUEUE_SIZE: usize = 1000;
enum SessionKind {
OAuth2,
Compat,
/// Session associated with personal access tokens
Personal,
Browser,
}

Expand All @@ -32,6 +34,7 @@ impl SessionKind {
match self {
SessionKind::OAuth2 => "oauth2",
SessionKind::Compat => "compat",
SessionKind::Personal => "personal",
SessionKind::Browser => "browser",
}
}
Expand Down Expand Up @@ -108,6 +111,28 @@ impl ActivityTracker {
}
}

/// Record activity in a personal access token session.
pub async fn record_personal_access_token_session(
&self,
clock: &dyn Clock,
session: &Session,
ip: Option<IpAddr>,
) {
let res = self
.channel
.send(Message::Record {
kind: SessionKind::Personal,
id: session.id,
date_time: clock.now(),
ip,
})
.await;

if let Err(e) = res {
tracing::error!("Failed to record Personal session: {}", e);
}
}

/// Record activity in a compat session.
pub async fn record_compat_session(
&self,
Expand Down
5 changes: 5 additions & 0 deletions crates/handlers/src/activity_tracker/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ impl Worker {
let mut browser_sessions = Vec::new();
let mut oauth2_sessions = Vec::new();
let mut compat_sessions = Vec::new();
let mut personal_sessions = Vec::new();

for ((kind, id), record) in pending_records {
match kind {
Expand All @@ -236,6 +237,9 @@ impl Worker {
SessionKind::Compat => {
compat_sessions.push((*id, record.end_time, record.ip));
}
SessionKind::Personal => {
personal_sessions.push((*id, record.end_time, record.ip));
}
}
}

Expand All @@ -253,6 +257,7 @@ impl Worker {
repo.compat_session()
.record_batch_activity(compat_sessions)
.await?;
// TODO: personal sessions: record

repo.save().await?;
self.pending_records.clear();
Expand Down
5 changes: 5 additions & 0 deletions crates/handlers/src/oauth2/introspection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,11 @@ pub(crate) async fn post(
device_id: session.device.map(Device::into),
}
}

TokenType::PersonalAccessToken => {
// TODO
return Err(RouteError::UnknownToken(TokenType::PersonalAccessToken));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few things I have in mind: this needs to check that both the actor and the owner are still active.

Deactivating (not locking) users should revoke all their tokens

Validating those tokens should also be done for requests to the GraphQL endpoint (optional maybe, we want to deprecate 'admin' access to the GraphQL API) and for the admin API access

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noted down. Given our plans with GraphQL I'm tempted to say that we just don't accept PATs there; cuts off one more opportunity for people to get locked into a deprecated API.

}
};

repo.save().await?;
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading