Files
continuwuity/src/service/oauth/mod.rs
T

903 lines
24 KiB
Rust

use std::{
collections::{BTreeSet, HashMap},
sync::Arc,
time::{Duration, SystemTime},
};
use base64::Engine;
use conduwuit::{
Result, SyncMutex, info,
utils::{self, hash::sha256},
};
use database::{Deserialized, Json, Map};
use itertools::Itertools;
use lru_cache::LruCache;
use rand::distr::{Distribution, slice::Choose};
use ruma::{DeviceId, OwnedDeviceId, OwnedUserId, UserId, api::OAuthClientScope};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::{
Dep, admin, config,
oauth::{
client_metadata::{ApplicationType, ClientMetadata, GrantType, ResponseType},
grant::{
AuthorizationCodeQuery, AuthorizationCodeResponse, CodeChallengeMethod,
DeviceCodeRequest, DeviceCodeResponse, ErrorCode, OAuthError, RawScopes,
RequestedScopes, ResponseMode, TokenRequest, TokenRequestType, TokenResponse,
TokenType,
},
},
users::{self, DeviceToken},
};
pub mod client_metadata;
pub mod grant;
pub struct Service {
services: Services,
db: Data,
tickets: SyncMutex<HashMap<String, HashMap<OAuthTicket, SystemTime>>>,
pending_auth_code_grants: SyncMutex<LruCache<String, PendingAuthCodeGrant>>,
pending_device_code_grants: SyncMutex<LruCache<String, PendingDeviceCodeGrant>>,
client_credential_sessions: tokio::sync::Mutex<HashMap<String, ClientCredentialsSession>>,
}
struct Data {
clientid_clientmetadata: Arc<Map>,
userdeviceid_oauthsessioninfo: Arc<Map>,
refreshtoken_refreshtokeninfo: Arc<Map>,
}
struct Services {
admin: Dep<admin::Service>,
users: Dep<users::Service>,
config: Dep<config::Service>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct SessionInfo {
pub client_id: String,
#[serde(deserialize_with = "client_metadata::btreeset_skip_err")]
scopes: BTreeSet<OAuthClientScope>,
#[serde(default)]
temporary_scopes: BTreeSet<OAuthClientScope>,
#[serde(default)]
current_refresh_token: String,
}
impl SessionInfo {
#[must_use]
pub fn scopes(&self) -> BTreeSet<OAuthClientScope> {
self.scopes.union(&self.temporary_scopes).cloned().collect()
}
}
#[derive(Debug, Deserialize, Serialize)]
struct RefreshTokenInfo {
client_id: String,
user_id: OwnedUserId,
device_id: OwnedDeviceId,
}
pub struct ClientCredentialsSession {
pub client_id: String,
pub scopes: BTreeSet<OAuthClientScope>,
created_at: SystemTime,
}
struct PendingAuthCodeGrant {
authorizing_user: OwnedUserId,
requested_scopes: RequestedScopes,
client_name: Option<String>,
expected_client_id: String,
expected_redirect_uri: Url,
code_challenge: String,
requested_at: SystemTime,
}
impl PendingAuthCodeGrant {
const MAX_AGE: Duration = Duration::from_mins(1);
#[must_use]
pub(crate) fn is_valid_for(&self, client_id: &str) -> bool {
let now = SystemTime::now();
self.expected_client_id == client_id
&& now
.duration_since(self.requested_at)
.is_ok_and(|age| age < Self::MAX_AGE)
}
}
struct PendingDeviceCodeGrant {
state: DeviceCodeGrantState,
requested_scopes: RequestedScopes,
client_name: Option<String>,
client_id: String,
requested_at: SystemTime,
}
enum DeviceCodeGrantState {
Unverified {
user_code: String,
},
Verified {
authorizing_user: OwnedUserId,
},
}
impl PendingDeviceCodeGrant {
const MAX_AGE: Duration = Duration::from_mins(1);
#[must_use]
pub(crate) fn is_valid_for(&self, client_id: &str) -> bool {
let now = SystemTime::now();
self.client_id == client_id
&& now
.duration_since(self.requested_at)
.is_ok_and(|age| age < Self::MAX_AGE)
}
}
pub struct DeviceCodeGrantInfo {
pub device_code: String,
pub client_metadata: ClientMetadata,
pub requested_scopes: RequestedScopes,
}
/// A time-limited grant for a client to perform some sensitive action.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum OAuthTicket {
CrossSigningReset,
}
impl OAuthTicket {
const MAX_AGE: Duration = Duration::from_mins(10);
#[must_use]
pub fn ticket_issue_path(&self) -> &'static str {
match self {
| Self::CrossSigningReset => "/account/cross_signing_reset",
}
}
}
impl crate::Service for Service {
fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
Ok(Arc::new(Self {
services: Services {
admin: args.depend::<admin::Service>("admin"),
users: args.depend::<users::Service>("users"),
config: args.depend::<config::Service>("config"),
},
db: Data {
clientid_clientmetadata: args.db["clientid_clientmetadata"].clone(),
userdeviceid_oauthsessioninfo: args.db["userdeviceid_oauthsessioninfo"].clone(),
refreshtoken_refreshtokeninfo: args.db["refreshtoken_refreshtokeninfo"].clone(),
},
tickets: SyncMutex::default(),
pending_auth_code_grants: SyncMutex::new(LruCache::new(Self::MAX_PENDING_GRANTS)),
pending_device_code_grants: SyncMutex::new(LruCache::new(Self::MAX_PENDING_GRANTS)),
client_credential_sessions: tokio::sync::Mutex::default(),
}))
}
fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
}
impl Service {
const ACCESS_TOKEN_MAX_AGE: Duration = Duration::from_hours(1);
// Maximum number of pending code grants which will be held in memory at
// once, to prevent unbounded memory use if someone decides to repeatedly
// reload the grant page.
const MAX_PENDING_GRANTS: usize = 100;
const RANDOM_TOKEN_LENGTH: usize = 32;
const USER_CODE_CHARACTERS: &[char] = &['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
const USER_CODE_LENGTH: usize = 6;
fn generate_token() -> String { utils::random_string(Self::RANDOM_TOKEN_LENGTH) }
fn generate_user_code() -> String {
Choose::new(Self::USER_CODE_CHARACTERS)
.unwrap()
.sample_iter(&mut rand::rng())
.take(Self::USER_CODE_LENGTH)
.collect()
}
pub async fn register_client(&self, metadata: &ClientMetadata) -> Result<String, OAuthError> {
metadata.validate().map_err(|error| OAuthError {
error: ErrorCode::InvalidClientMetadata,
error_description: error.into(),
})?;
let client_id = base64::prelude::BASE64_STANDARD
.encode(sha256::hash(serde_json::to_string(metadata).unwrap().as_bytes()));
if self
.db
.clientid_clientmetadata
.exists(&client_id)
.await
.is_err()
{
self.db
.clientid_clientmetadata
.raw_put(&client_id, Json(metadata.clone()));
}
Ok(client_id)
}
pub async fn get_client_metadata(&self, client_id: &str) -> Option<ClientMetadata> {
self.db
.clientid_clientmetadata
.get(client_id)
.await
.deserialized()
.ok()
}
pub async fn get_session_info_for_device(
&self,
user_id: &UserId,
device_id: &DeviceId,
) -> Option<SessionInfo> {
self.db
.userdeviceid_oauthsessioninfo
.qry(&(user_id, device_id))
.await
.deserialized::<SessionInfo>()
.ok()
}
pub async fn request_authorization_code(
&self,
authorizing_user: OwnedUserId,
query: AuthorizationCodeQuery,
) -> Result<String, String> {
let Some(client_metadata) = self.get_client_metadata(&query.client_id).await else {
return Err("Invalid client ID".to_owned());
};
if !(client_metadata
.response_types
.contains(&query.response_type)
&& matches!(query.response_type, ResponseType::Code))
{
return Err("Invalid response type".to_owned());
}
if !matches!(query.code_challenge_method, CodeChallengeMethod::S256) {
return Err("Invalid code challenge type".to_owned());
}
{
let mut stripped_uri = query.redirect_uri.clone();
if client_metadata.application_type == ApplicationType::Native
&& query
.redirect_uri
.host_str()
.is_some_and(|host| ClientMetadata::ACCEPTABLE_LOCALHOSTS.contains(&host))
{
// Remove the port from localhost redirect URIs for native
// applications when checking if it's valid
stripped_uri.set_port(None).unwrap();
}
if !client_metadata.redirect_uris.contains(&stripped_uri) {
return Err("Invalid redirect URI".to_owned());
}
}
let requested_scopes = query.scope.to_scopes()?;
let redirect_uri_query_separator = match query.response_mode {
| ResponseMode::Fragment => '#',
| ResponseMode::Query => '?',
};
let code = Self::generate_token();
info!(
client_id = &query.client_id,
client_name = &client_metadata.client_name,
?requested_scopes,
?authorizing_user,
"Issuing OAuth authorization code"
);
let redirect_uri = format!(
"{}{}{}",
query.redirect_uri,
redirect_uri_query_separator,
serde_urlencoded::to_string(AuthorizationCodeResponse {
state: query.state,
code: code.clone(),
})
.unwrap(),
);
let pending_grant = PendingAuthCodeGrant {
authorizing_user,
requested_scopes,
client_name: client_metadata.client_name,
expected_client_id: query.client_id,
expected_redirect_uri: query.redirect_uri,
code_challenge: query.code_challenge,
requested_at: SystemTime::now(),
};
self.pending_auth_code_grants
.lock()
.insert(code, pending_grant);
Ok(redirect_uri)
}
pub async fn request_device_code(
&self,
query: DeviceCodeRequest,
) -> Result<DeviceCodeResponse, OAuthError> {
let Some(client_metadata) = self.get_client_metadata(&query.client_id).await else {
return Err(OAuthError::new_static(ErrorCode::InvalidClient, "Invalid client ID"));
};
if !client_metadata.grant_types.contains(&GrantType::DeviceCode) {
return Err(OAuthError::unauthorized_client("Client cannot request this grant type"));
}
let requested_scopes = query
.scope
.to_scopes()
.map_err(|err| OAuthError::new(ErrorCode::InvalidGrant, err))?;
let device_code = Self::generate_token();
let user_code = Self::generate_user_code();
let verification_uri = self
.services
.config
.get_client_domain()
.join(&format!("{}/oauth2/grant/device_code", conduwuit::ROUTE_PREFIX))
.unwrap();
let mut verification_uri_complete = verification_uri.clone();
verification_uri_complete
.query_pairs_mut()
.append_pair("user_code", &user_code);
info!(
client_id = &query.client_id,
client_name = &client_metadata.client_name,
?requested_scopes,
"Issuing OAuth device code"
);
let pending_grant = PendingDeviceCodeGrant {
state: DeviceCodeGrantState::Unverified { user_code: user_code.clone() },
requested_scopes,
client_name: client_metadata.client_name,
client_id: query.client_id,
requested_at: SystemTime::now(),
};
self.pending_device_code_grants
.lock()
.insert(device_code.clone(), pending_grant);
Ok(DeviceCodeResponse {
device_code,
user_code,
verification_uri,
verification_uri_complete: Some(verification_uri_complete),
expires_in: PendingDeviceCodeGrant::MAX_AGE.as_secs(),
})
}
pub async fn grant_info_for_user_code(
&self,
supplied_user_code: &str,
) -> Option<DeviceCodeGrantInfo> {
let (device_code, client_id, requested_scopes) = {
let pending_grants = self.pending_device_code_grants.lock();
let (device_code, grant) = pending_grants
.iter()
.find(|(_, grant)| {
matches!(&grant.state, DeviceCodeGrantState::Unverified { user_code } if user_code == supplied_user_code)
})?;
(device_code.clone(), grant.client_id.clone(), grant.requested_scopes.clone())
};
let client_metadata = self
.get_client_metadata(&client_id)
.await
.expect("client should exist");
Some(DeviceCodeGrantInfo {
device_code,
client_metadata,
requested_scopes,
})
}
pub fn validate_device_code(
&self,
authorizing_user: OwnedUserId,
device_code: &str,
) -> Result<(), String> {
let mut pending_grants = self.pending_device_code_grants.lock();
let Some(pending_grant) = pending_grants.get_mut(device_code) else {
return Err("Invalid device code".to_owned());
};
match &mut pending_grant.state {
| state @ DeviceCodeGrantState::Unverified { .. } => {
*state = DeviceCodeGrantState::Verified { authorizing_user };
Ok(())
},
| DeviceCodeGrantState::Verified {
authorizing_user: previous_authorizing_user,
} =>
if *previous_authorizing_user == authorizing_user {
Ok(())
} else {
Err("Device code is already verified".to_owned())
},
}
}
pub async fn issue_token(&self, request: TokenRequest) -> Result<TokenResponse, OAuthError> {
let TokenRequest { client_id, request } = request;
if !matches!(request, TokenRequestType::ClientCredentials { .. }) {
// The client credentials grant can only be used by statically
// registered clients, and the other grant types can
// only be used by dynamically registered clients, so we
// don't look up the dynamic client metadata if the token request is
// for the client credentials grant.
let Some(client_metadata) = self.get_client_metadata(&client_id).await else {
return Err(OAuthError::new_static(
ErrorCode::InvalidClient,
"Invalid client ID",
));
};
if !client_metadata.grant_types.contains(&request.grant_type()) {
return Err(OAuthError::unauthorized_client(
"Client cannot request this grant type",
));
}
}
match request {
| TokenRequestType::AuthorizationCode { code, redirect_uri, code_verifier } => {
let pending_grant = {
let mut pending_grants = self.pending_auth_code_grants.lock();
let Some(pending_grant) = pending_grants
.remove(&code)
.filter(|grant| grant.is_valid_for(&client_id))
else {
return Err(OAuthError::invalid_grant("Invalid authorization code"));
};
if redirect_uri != pending_grant.expected_redirect_uri {
return Err(OAuthError::invalid_grant("Invalid redirect URI"));
}
let expected_code_challenge = base64::prelude::BASE64_URL_SAFE_NO_PAD
.encode(sha256::hash(&code_verifier));
if expected_code_challenge != pending_grant.code_challenge {
return Err(OAuthError::invalid_grant("Invalid code challenge"));
}
pending_grant
};
self.create_session(
pending_grant.authorizing_user,
pending_grant.requested_scopes,
pending_grant.client_name,
client_id,
)
.await
},
| TokenRequestType::DeviceCode { device_code } => {
let (authorizing_user, requested_scopes, client_name) = {
let mut pending_grants = self.pending_device_code_grants.lock();
let Some(pending_grant) = pending_grants
.remove(&device_code)
.filter(|grant| grant.is_valid_for(&client_id))
else {
return Err(OAuthError::new_static(
ErrorCode::ExpiredToken,
"Invalid device code",
));
};
match &pending_grant.state {
| DeviceCodeGrantState::Unverified { .. } => {
pending_grants.insert(device_code, pending_grant);
return Err(OAuthError::new_static(
ErrorCode::AuthorizationPending,
"Authorization is pending",
));
},
| DeviceCodeGrantState::Verified { authorizing_user } => (
authorizing_user.to_owned(),
pending_grant.requested_scopes,
pending_grant.client_name,
),
}
};
self.create_session(authorizing_user, requested_scopes, client_name, client_id)
.await
},
| TokenRequestType::RefreshToken { refresh_token } =>
self.refresh_session(client_id, refresh_token).await,
| TokenRequestType::ClientCredentials { client_secret, scope: scopes } =>
self.create_client_credentials_token(&client_id, &client_secret, scopes)
.await,
}
}
pub async fn revoke_token(&self, token: String) -> Result<(), OAuthError> {
let (user_id, device_id) = if let Ok(refresh_token_info) = self
.db
.refreshtoken_refreshtokeninfo
.get(&token)
.await
.deserialized::<RefreshTokenInfo>()
{
(refresh_token_info.user_id, refresh_token_info.device_id)
} else if let Some((user_id, device_id, _)) =
self.services.users.find_from_token(&token).await
{
(user_id, device_id)
} else {
return Err(OAuthError::invalid_grant("Invalid access or refersh token"));
};
// This will also call [`Self::remove_session`]
self.services
.users
.remove_device(&user_id, &device_id)
.await;
Ok(())
}
async fn create_session(
&self,
authorizing_user: OwnedUserId,
requested_scopes: RequestedScopes,
client_name: Option<String>,
client_id: String,
) -> Result<TokenResponse, OAuthError> {
self.check_requested_scopes(&authorizing_user, &requested_scopes.scopes)
.await?;
let access_token = DeviceToken::new_random().with_max_age(Self::ACCESS_TOKEN_MAX_AGE);
let response_scope = requested_scopes.to_string();
let device_id = requested_scopes
.device_id
.ok_or_else(|| OAuthError::invalid_grant("No device ID scope supplied"))?;
let session_info = if self
.services
.users
.get_device_metadata(&authorizing_user, &device_id)
.await
.is_ok()
{
// We're doing step-up authentication for an existing device
let Some(mut session_info) = self
.get_session_info_for_device(&authorizing_user, &device_id)
.await
else {
// The device ID doesn't refer to an OAuth device
return Err(OAuthError::new_static(
ErrorCode::InvalidScope,
"A device with the supplied ID already exists for this user",
));
};
if session_info.client_id != client_id {
// This device was created by a different piece of client
// software. Either the client stepping up
// changed its registration without logging out
// or someone is screwing with things.
return Err(OAuthError::invalid_grant(
"The supplied device was created by a different client",
));
}
session_info.temporary_scopes = requested_scopes.scopes;
self.services
.users
.set_token(&authorizing_user, &device_id, access_token.clone())
.await
.expect("should be able to set access token");
info!(
?client_id,
?authorizing_user,
?device_id,
scopes = ?session_info.temporary_scopes,
"Granted temporary scopes to device"
);
session_info
} else {
self.services
.users
.create_device(
&authorizing_user,
Some(device_id.clone()),
Some(access_token.clone()),
client_name,
None,
)
.await
// This can only panic if the authorizing user suffered a spontaneous existence
// failure during authentication, which should(?) be impossible(?)
.expect("should be able to create device");
info!(
?client_id,
?authorizing_user,
?device_id,
scopes = ?requested_scopes.scopes,
"Created new OAuth device"
);
SessionInfo {
client_id: client_id.clone(),
current_refresh_token: Self::generate_token(),
scopes: requested_scopes.scopes,
temporary_scopes: BTreeSet::new(),
}
};
let refresh_token = session_info.current_refresh_token.clone();
self.db
.userdeviceid_oauthsessioninfo
.put((&authorizing_user, &device_id), Json(session_info));
self.db.refreshtoken_refreshtokeninfo.raw_put(
&refresh_token,
Json(RefreshTokenInfo {
client_id,
user_id: authorizing_user,
device_id,
}),
);
Ok(TokenResponse {
access_token: access_token.into_token(),
token_type: TokenType::Bearer,
expires_in: Self::ACCESS_TOKEN_MAX_AGE.as_secs(),
scope: response_scope,
refresh_token: Some(refresh_token),
})
}
async fn refresh_session(
&self,
client_id: String,
refresh_token: String,
) -> Result<TokenResponse, OAuthError> {
let Some(refresh_token_info) = self
.db
.refreshtoken_refreshtokeninfo
.get(&refresh_token)
.await
.deserialized::<RefreshTokenInfo>()
.ok()
else {
return Err(OAuthError::invalid_grant("Invalid refresh token"));
};
assert_eq!(&client_id, &refresh_token_info.client_id, "refresh token client id mismatch");
let mut session_info = self
.get_session_info_for_device(
&refresh_token_info.user_id,
&refresh_token_info.device_id,
)
.await
.expect("session info should exist");
assert_eq!(&client_id, &session_info.client_id, "session info client id mismatch");
// TODO: this doesn't include the device ID "scope", do any clients rely
// on that?
let scope = session_info.scopes.iter().join(" ");
let new_access_token = DeviceToken::new_random().with_max_age(Self::ACCESS_TOKEN_MAX_AGE);
let new_refresh_token = Self::generate_token();
session_info
.current_refresh_token
.clone_from(&new_refresh_token);
self.services
.users
.set_token(
&refresh_token_info.user_id,
&refresh_token_info.device_id,
new_access_token.clone(),
)
.await
.expect("should be able to set token");
session_info.temporary_scopes.clear();
self.db.userdeviceid_oauthsessioninfo.put(
(&refresh_token_info.user_id, &refresh_token_info.device_id),
Json(session_info),
);
self.db.refreshtoken_refreshtokeninfo.remove(&refresh_token);
drop(refresh_token);
self.db
.refreshtoken_refreshtokeninfo
.raw_put(&new_refresh_token, Json(refresh_token_info));
Ok(TokenResponse {
access_token: new_access_token.into_token(),
token_type: TokenType::Bearer,
expires_in: Self::ACCESS_TOKEN_MAX_AGE.as_secs(),
scope,
refresh_token: Some(new_refresh_token),
})
}
async fn create_client_credentials_token(
&self,
client_id: &str,
client_secret: &str,
scopes: RawScopes,
) -> Result<TokenResponse, OAuthError> {
let now = SystemTime::now();
let Some(client) = self.services.config.oauth.clients.get(client_id) else {
return Err(OAuthError::new_static(ErrorCode::InvalidClient, "Unknown client ID"));
};
let RequestedScopes { device_id, scopes } = scopes
.to_scopes()
.map_err(|err| OAuthError::new(ErrorCode::InvalidGrant, err))?;
if device_id.is_some() {
return Err(OAuthError::new_static(
ErrorCode::InvalidGrant,
"Static clients may not request a device ID",
));
}
if client_secret != client.client_secret {
return Err(OAuthError::new_static(
ErrorCode::InvalidClient,
"Client secret does not match",
));
}
if !scopes.is_subset(&client.scopes) {
return Err(OAuthError::new_static(
ErrorCode::InvalidScope,
"This client may not request those scopes",
));
}
let mut sessions = self.client_credential_sessions.lock().await;
// Clear expired sessions while we're at it
sessions.retain(|_, session| {
now.duration_since(session.created_at)
.is_ok_and(|age| age <= Self::ACCESS_TOKEN_MAX_AGE)
});
let access_token = Self::generate_token();
let scope = scopes.iter().join(" ");
sessions.insert(access_token.clone(), ClientCredentialsSession {
client_id: client_id.to_owned(),
scopes,
created_at: now,
});
Ok(TokenResponse {
access_token,
token_type: TokenType::Bearer,
expires_in: Self::ACCESS_TOKEN_MAX_AGE.as_secs(),
refresh_token: None,
scope,
})
}
pub async fn get_client_credentials_session(
&self,
token: &str,
) -> Option<tokio::sync::MappedMutexGuard<'_, ClientCredentialsSession>> {
let sessions = self.client_credential_sessions.lock().await;
tokio::sync::MutexGuard::try_map(sessions, |sessions| {
sessions.get_mut(token).filter(|session| {
SystemTime::now()
.duration_since(session.created_at)
.is_ok_and(|age| age <= Self::ACCESS_TOKEN_MAX_AGE)
})
})
.ok()
}
pub async fn remove_session(&self, user_id: &UserId, device_id: &DeviceId) {
let session_info = self.get_session_info_for_device(user_id, device_id).await;
if let Some(session_info) = session_info {
self.db
.refreshtoken_refreshtokeninfo
.remove(&session_info.current_refresh_token);
self.db
.userdeviceid_oauthsessioninfo
.del((user_id, device_id));
info!(?user_id, ?device_id, "Removed OAuth session");
}
}
async fn check_requested_scopes(
&self,
authorizing_user: &UserId,
scopes: &BTreeSet<OAuthClientScope>,
) -> Result<(), OAuthError> {
if scopes.contains(&OAuthClientScope::ServerAdministration)
&& !self.services.admin.user_is_admin(authorizing_user).await
{
return Err(OAuthError::new(
ErrorCode::AccessDenied,
"You are not a server administrator.".to_owned(),
));
}
Ok(())
}
/// Issue a ticket for `localpart` to perform some action.
pub fn issue_ticket(&self, localpart: String, ticket: OAuthTicket) {
self.tickets
.lock()
.entry(localpart)
.or_default()
.insert(ticket, SystemTime::now());
}
/// Try to consume an unexpired ticket for `localpart`.
pub fn try_consume_ticket(&self, localpart: &str, ticket: OAuthTicket) -> bool {
let now = SystemTime::now();
self.tickets
.lock()
.get_mut(localpart)
.and_then(|tickets| tickets.remove(&ticket))
.is_some_and(|issued| {
now.duration_since(issued)
.is_ok_and(|duration| duration < OAuthTicket::MAX_AGE)
})
}
}