use std::fmt;
use std::time::Duration;
use ferrohealth_secret::secret::Secret;
use ferrohealth_ui::view::session::Left;
use serde::{Deserialize, Serialize};
use crate::lifetime::{from_seconds, millis, seconds_down, seconds_up};
#[derive(Clone, PartialEq, Eq)]
pub enum Credential {
Bearer(Secret),
Basic {
username: String,
password: Secret,
},
}
impl fmt::Debug for Credential {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Bearer(_) => f.write_str("Bearer(..)"),
Self::Basic { username, .. } => f
.debug_struct("Basic")
.field("username", username)
.finish_non_exhaustive(),
}
}
}
pub struct SignedIn {
pub(crate) operator: Option<String>,
pub(crate) subject: Option<String>,
pub(crate) scopes: Vec<String>,
pub(crate) credential: Credential,
pub(crate) expires_in: Option<Duration>,
pub(crate) refresh_token: Option<Secret>,
pub(crate) id_token: Option<Secret>,
}
impl fmt::Debug for SignedIn {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SignedIn")
.field("operator", &self.operator)
.field("subject", &self.subject)
.field("scopes", &self.scopes)
.field("credential", &self.credential)
.field("expires_in", &self.expires_in)
.finish_non_exhaustive()
}
}
impl SignedIn {
#[must_use]
pub fn from_oidc(signed_in: ferrohealth_smart::oidc::SignedIn) -> Self {
Self {
operator: signed_in.claims.display_name().map(str::to_owned),
subject: Some(signed_in.claims.subject.clone()),
scopes: signed_in.scopes,
credential: Credential::Bearer(signed_in.access_token),
expires_in: signed_in.expires_in,
refresh_token: signed_in.refresh_token,
id_token: Some(signed_in.id_token),
}
}
#[must_use]
pub fn basic(username: impl Into<String>, password: Secret) -> Self {
let username = username.into();
Self {
operator: Some(username.clone()),
subject: None,
scopes: Vec::new(),
credential: Credential::Basic { username, password },
expires_in: None,
refresh_token: None,
id_token: None,
}
}
}
#[derive(Clone)]
pub struct Session {
pub(crate) operator: Option<String>,
pub(crate) subject: Option<String>,
pub(crate) scopes: Vec<String>,
pub(crate) credential: Credential,
pub(crate) signed_in_at: i64,
pub(crate) left: Left,
}
impl fmt::Debug for Session {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Session")
.field("operator", &self.operator)
.field("scopes", &self.scopes)
.field("credential", &self.credential)
.field("signed_in_at", &self.signed_in_at)
.field("left", &self.left)
.finish_non_exhaustive()
}
}
impl Session {
#[must_use]
pub fn operator(&self) -> Option<&str> {
self.operator.as_deref()
}
#[must_use]
pub fn subject(&self) -> Option<&str> {
self.subject.as_deref()
}
#[must_use]
pub fn scopes(&self) -> &[String] {
&self.scopes
}
#[must_use]
pub const fn credential(&self) -> &Credential {
&self.credential
}
#[must_use]
pub const fn signed_in_at(&self) -> i64 {
seconds_up(self.signed_in_at)
}
#[must_use]
pub const fn left(&self) -> Left {
self.left
}
}
#[derive(Debug, Clone)]
pub enum Held {
SignedIn(Session),
TokenExpired,
Ended,
Absent,
}
pub struct Ended {
pub id_token: Option<Secret>,
pub revocable: Option<(Secret, ferrohealth_smart::oidc::TokenTypeHint)>,
}
impl fmt::Debug for Ended {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Ended")
.field("hint", &self.revocable.as_ref().map(|(_, hint)| hint))
.finish_non_exhaustive()
}
}
#[derive(Clone)]
pub(crate) struct Record {
pub(crate) operator: Option<String>,
pub(crate) subject: Option<String>,
pub(crate) scopes: Vec<String>,
pub(crate) credential: Credential,
pub(crate) expires: Option<i64>,
pub(crate) lifetime: Option<Duration>,
pub(crate) refresh_token: Option<Secret>,
pub(crate) id_token: Option<Secret>,
pub(crate) signed_in: i64,
pub(crate) touched: i64,
}
impl Record {
pub(crate) fn begin(signed_in: SignedIn, now: i64) -> Self {
let mut record = Self {
operator: signed_in.operator,
subject: signed_in.subject,
scopes: signed_in.scopes,
credential: signed_in.credential,
expires: None,
lifetime: None,
refresh_token: signed_in.refresh_token,
id_token: signed_in.id_token,
signed_in: now,
touched: now,
};
record.token_issued(signed_in.expires_in, now);
record
}
pub(crate) fn token_issued(&mut self, lifetime: Option<Duration>, now: i64) {
self.expires = lifetime.map(|lifetime| now.saturating_add(millis(lifetime)));
self.lifetime = lifetime;
}
pub(crate) fn times(&self) -> crate::lifetime::Times {
crate::lifetime::Times {
signed_in: self.signed_in,
touched: self.touched,
last_token: if self.refresh_token.is_some() {
None
} else {
self.expires
},
}
}
pub(crate) fn token_valid(&self, now: i64) -> bool {
self.expires.is_none_or(|expires| now < expires)
}
pub(crate) fn session(&self, left: Left) -> Session {
Session {
operator: self.operator.clone(),
subject: self.subject.clone(),
scopes: self.scopes.clone(),
credential: self.credential.clone(),
signed_in_at: self.signed_in,
left,
}
}
pub(crate) fn ended(self) -> Ended {
use ferrohealth_smart::oidc::TokenTypeHint;
let revocable = match (self.refresh_token, self.credential) {
(Some(refresh), _) => Some((refresh, TokenTypeHint::RefreshToken)),
(None, Credential::Bearer(access)) => Some((access, TokenTypeHint::AccessToken)),
(None, Credential::Basic { .. }) => None,
};
Ended {
id_token: self.id_token,
revocable,
}
}
}
#[derive(Serialize, Deserialize)]
pub(crate) struct Sealed {
operator: Option<String>,
subject: Option<String>,
scopes: Vec<String>,
bearer: Option<String>,
basic: Option<(String, String)>,
expires: Option<i64>,
lifetime: Option<u64>,
id_token: Option<String>,
signed_in: i64,
touched: i64,
}
impl From<&Record> for Sealed {
fn from(record: &Record) -> Self {
let (bearer, basic) = match &record.credential {
Credential::Bearer(token) => (Some(token.expose().to_owned()), None),
Credential::Basic { username, password } => {
(None, Some((username.clone(), password.expose().to_owned())))
}
};
Self {
operator: record.operator.clone(),
subject: record.subject.clone(),
scopes: record.scopes.clone(),
bearer,
basic,
expires: record.expires.map(seconds_down),
lifetime: record.lifetime.map(|lifetime| lifetime.as_secs()),
id_token: record
.id_token
.as_ref()
.map(|token| token.expose().to_owned()),
signed_in: seconds_up(record.signed_in),
touched: seconds_up(record.touched),
}
}
}
impl Sealed {
pub(crate) fn record(self) -> Option<Record> {
let credential = match (self.bearer, self.basic) {
(Some(token), None) => Credential::Bearer(Secret::new(token)),
(None, Some((username, password))) => Credential::Basic {
username,
password: Secret::new(password),
},
_ => return None,
};
Some(Record {
operator: self.operator,
subject: self.subject,
scopes: self.scopes,
credential,
expires: self.expires.map(from_seconds),
lifetime: self.lifetime.map(Duration::from_secs),
refresh_token: None,
id_token: self.id_token.map(Secret::new),
signed_in: from_seconds(self.signed_in),
touched: from_seconds(self.touched),
})
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use ferrohealth_secret::secret::Secret;
use super::{Credential, Record, Sealed, SignedIn};
const START: i64 = 1_800_000_000;
const LATE: i64 = START * 1000 + 999;
fn record() -> Record {
Record::begin(
SignedIn {
operator: Some(String::from("Synthetic Operator")),
subject: None,
scopes: Vec::new(),
credential: Credential::Bearer(Secret::new("synthetic-access-token")),
expires_in: Some(Duration::from_secs(30)),
refresh_token: None,
id_token: None,
},
LATE,
)
}
#[test]
fn a_sealed_record_keeps_whole_seconds_rounded_toward_a_longer_life() {
let sealed = serde_json::to_value(Sealed::from(&record())).expect("serialized");
assert_eq!(
(Some(START + 1), Some(START + 1), Some(START + 30), Some(30)),
(
sealed["signed_in"].as_i64(),
sealed["touched"].as_i64(),
sealed["expires"].as_i64(),
sealed["lifetime"].as_u64()
),
"the sign-in and activity rounded up, the token's expiry down: {sealed}"
);
}
#[test]
fn a_record_sealed_in_whole_seconds_reads_back_in_milliseconds() {
let earlier = serde_json::json!({
"operator": "Synthetic Operator",
"subject": null,
"scopes": [],
"bearer": "synthetic-access-token",
"basic": null,
"expires": START + 30,
"lifetime": 30,
"id_token": null,
"signed_in": START,
"touched": START + 10
});
let record = serde_json::from_value::<Sealed>(earlier)
.expect("the earlier format reads")
.record()
.expect("a credential");
assert_eq!(
(
START * 1000,
(START + 10) * 1000,
Some((START + 30) * 1000),
Some(Duration::from_secs(30))
),
(
record.signed_in,
record.touched,
record.expires,
record.lifetime
)
);
}
}