use std::fmt;
use std::time::{Duration, Instant};
use chrono::{DateTime, Utc};
use secrecy::{ExposeSecret, SecretString};
use serde::Deserialize;
use sha1::{Digest, Sha1};
#[derive(Clone)]
pub struct Credentials(pub(crate) Kind);
#[derive(Clone)]
pub(crate) enum Kind {
None,
AccessToken(SecretString),
ApiSecret(SecretString),
}
impl Credentials {
#[must_use]
pub const fn none() -> Self {
Self(Kind::None)
}
#[must_use]
pub fn access_token(token: impl Into<String>) -> Self {
Self(Kind::AccessToken(SecretString::from(
token.into().trim().to_owned(),
)))
}
#[must_use]
pub fn api_secret(secret: impl Into<String>) -> Self {
let secret = SecretString::from(secret.into());
let digest = Sha1::digest(secret.expose_secret().as_bytes());
let hex = digest.iter().fold(String::with_capacity(40), |mut s, b| {
use fmt::Write;
let _ = write!(s, "{b:02x}");
s
});
Self(Kind::ApiSecret(SecretString::from(hex)))
}
#[must_use]
pub const fn is_none(&self) -> bool {
matches!(self.0, Kind::None)
}
pub(crate) const fn is_access_token(&self) -> bool {
matches!(self.0, Kind::AccessToken(_))
}
pub(crate) const fn is_api_secret(&self) -> bool {
matches!(self.0, Kind::ApiSecret(_))
}
pub(crate) fn access_token_secret(&self) -> Option<&SecretString> {
match &self.0 {
Kind::AccessToken(t) => Some(t),
_ => None,
}
}
#[cfg_attr(not(feature = "realtime"), allow(dead_code))]
pub(crate) fn api_secret_hash(&self) -> Option<&SecretString> {
match &self.0 {
Kind::ApiSecret(h) => Some(h),
_ => None,
}
}
}
impl Default for Credentials {
fn default() -> Self {
Self::none()
}
}
impl fmt::Debug for Credentials {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self.0 {
Kind::None => "Credentials::None",
Kind::AccessToken(_) => "Credentials::AccessToken(<redacted>)",
Kind::ApiSecret(_) => "Credentials::ApiSecret(<redacted>)",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Session {
pub subject: Option<String>,
pub permission_groups: Vec<Vec<String>>,
pub issued_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
}
impl Session {
#[must_use]
pub fn has_permission(&self, wanted: &str) -> bool {
self.permission_groups
.iter()
.flatten()
.any(|granted| shiro_match(granted, wanted))
}
}
fn shiro_match(granted: &str, wanted: &str) -> bool {
let mut g = granted.split(':');
let mut w = wanted.split(':');
loop {
match (g.next(), w.next()) {
(None, _) => return true,
(Some(gp), Some(wp)) => {
if gp != "*" && !gp.split(',').any(|alt| alt == wp) {
return false;
}
}
(Some(gp), None) => return gp == "*",
}
}
}
#[derive(Deserialize)]
pub(crate) struct JwtResponse {
pub(crate) token: String,
#[serde(default)]
pub(crate) sub: Option<String>,
#[serde(default, rename = "permissionGroups")]
pub(crate) permission_groups: Vec<Vec<String>>,
pub(crate) iat: i64,
pub(crate) exp: i64,
}
pub(crate) struct Jwt {
pub(crate) token: SecretString,
pub(crate) session: Session,
pub(crate) obtained: Instant,
}
impl Jwt {
const REFRESH_MARGIN: Duration = Duration::from_secs(5 * 60);
pub(crate) fn from_response(resp: JwtResponse) -> Self {
let ts = |secs| DateTime::from_timestamp(secs, 0).unwrap_or_default();
Self {
token: SecretString::from(resp.token),
session: Session {
subject: resp.sub,
permission_groups: resp.permission_groups,
issued_at: ts(resp.iat),
expires_at: ts(resp.exp),
},
obtained: Instant::now(),
}
}
pub(crate) fn is_fresh(&self) -> bool {
let lifetime = (self.session.expires_at - self.session.issued_at)
.to_std()
.unwrap_or_default();
self.obtained.elapsed() + Self::REFRESH_MARGIN < lifetime
}
}
impl fmt::Debug for Jwt {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Jwt")
.field("token", &"<redacted>")
.field("session", &self.session)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn api_secret_is_hashed_and_redacted() {
let creds = Credentials::api_secret("cinnamon-e2e-secret");
let hash = creds
.api_secret_hash()
.map(|h| h.expose_secret().to_owned())
.unwrap_or_default();
assert_eq!(hash, "30143ac058893ef38d5e090c9719103474b9ec19");
assert_eq!(format!("{creds:?}"), "Credentials::ApiSecret(<redacted>)");
}
#[test]
fn jwt_freshness_ignores_clock_skew() {
let jwt = |iat: i64, exp: i64| {
Jwt::from_response(JwtResponse {
token: "jwt".into(),
sub: None,
permission_groups: Vec::new(),
iat,
exp,
})
};
assert!(jwt(631_152_000, 631_152_000 + 8 * 3600).is_fresh());
assert!(!jwt(631_152_000, 631_152_000 + 60).is_fresh());
}
#[test]
fn shiro_matching() {
assert!(shiro_match("*", "api:treatments:create"));
assert!(shiro_match("*:*:read", "api:entries:read"));
assert!(!shiro_match("*:*:read", "api:entries:create"));
assert!(shiro_match(
"api:treatments:create",
"api:treatments:create"
));
assert!(shiro_match("api:pebble,entries:read", "api:entries:read"));
assert!(!shiro_match("api:treatments", "api"));
assert!(shiro_match("api", "api:treatments:read"));
}
}