use std::time::{Duration, SystemTime};
use serde::{Deserialize, Serialize};
use crate::dpop::{compute_access_token_hash, DPoPKey};
use crate::error::{AtprotoOAuthError, DPoPError, TokenError};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OAuthSession {
pub sub: String,
pub access_token: String,
pub refresh_token: Option<String>,
pub token_type: String,
pub scope: Option<String>,
pub expires_at: Option<SystemTime>,
pub dpop_key: DPoPKey,
pub pds_endpoint: Option<String>,
pub auth_server_issuer: Option<String>,
pub token_endpoint: Option<String>,
pub created_at: SystemTime,
}
impl OAuthSession {
#[allow(clippy::too_many_arguments)]
pub fn new(
sub: impl Into<String>,
access_token: impl Into<String>,
refresh_token: Option<String>,
token_type: impl Into<String>,
scope: Option<String>,
expires_in_secs: Option<u64>,
dpop_key: DPoPKey,
pds_endpoint: Option<String>,
auth_server_issuer: Option<String>,
token_endpoint: Option<String>,
) -> Result<Self, AtprotoOAuthError> {
let ttype = token_type.into();
if !ttype.eq_ignore_ascii_case("DPoP") {
return Err(TokenError::InvalidTokenType(ttype).into());
}
let now = SystemTime::now();
let expires_at =
expires_in_secs.and_then(|secs| now.checked_add(Duration::from_secs(secs)));
Ok(Self {
sub: sub.into(),
access_token: access_token.into(),
refresh_token,
token_type: ttype,
scope,
expires_at,
dpop_key,
pds_endpoint,
auth_server_issuer,
token_endpoint,
created_at: now,
})
}
#[must_use]
pub fn is_expired(&self) -> bool {
self.is_expired_with_leeway(Duration::ZERO)
}
#[must_use]
pub fn is_expired_with_leeway(&self, leeway: Duration) -> bool {
match self.expires_at {
Some(exp) => {
let now = SystemTime::now();
match now.checked_add(leeway) {
Some(now_with_leeway) => exp <= now_with_leeway,
None => true,
}
}
None => false,
}
}
pub fn rotate_tokens(
&mut self,
access_token: impl Into<String>,
refresh_token: Option<String>,
expires_in_secs: Option<u64>,
) {
self.access_token = access_token.into();
self.refresh_token = refresh_token;
let now = SystemTime::now();
self.expires_at =
expires_in_secs.and_then(|secs| now.checked_add(Duration::from_secs(secs)));
}
pub fn create_dpop_proof(
&self,
htm: &str,
htu: &str,
nonce: Option<&str>,
) -> Result<String, DPoPError> {
let ath = compute_access_token_hash(&self.access_token);
self.dpop_key
.create_proof(htm, htu, nonce, Some(ath.as_str()))
}
#[must_use]
pub fn dpop_auth_header(&self) -> String {
format!("DPoP {}", self.access_token)
}
#[must_use]
pub fn sub(&self) -> &str {
&self.sub
}
#[must_use]
pub fn access_token(&self) -> &str {
&self.access_token
}
#[must_use]
pub fn refresh_token(&self) -> Option<&str> {
self.refresh_token.as_deref()
}
#[must_use]
pub fn token_type(&self) -> &str {
&self.token_type
}
#[must_use]
pub fn scope(&self) -> Option<&str> {
self.scope.as_deref()
}
#[must_use]
pub fn expires_at(&self) -> Option<SystemTime> {
self.expires_at
}
#[must_use]
pub fn dpop_key(&self) -> &DPoPKey {
&self.dpop_key
}
#[must_use]
pub fn pds_endpoint(&self) -> Option<&str> {
self.pds_endpoint.as_deref()
}
#[must_use]
pub fn auth_server_issuer(&self) -> Option<&str> {
self.auth_server_issuer.as_deref()
}
#[must_use]
pub fn token_endpoint(&self) -> Option<&str> {
self.token_endpoint.as_deref()
}
#[must_use]
pub fn created_at(&self) -> SystemTime {
self.created_at
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, missing_docs)]
mod tests {
use super::*;
use crate::dpop::DPoPVerifier;
#[test]
fn test_session_creation_valid() {
let key = DPoPKey::generate();
let session = OAuthSession::new(
"did:plc:alice123",
"at_sample_access_token",
Some("rt_sample_refresh_token".to_string()),
"DPoP",
Some("atproto transition:generic".to_string()),
Some(3600),
key.clone(),
Some("https://pds.example.com".to_string()),
Some("https://auth.example.com".to_string()),
Some("https://auth.example.com/oauth/token".to_string()),
)
.unwrap();
assert_eq!(session.sub(), "did:plc:alice123");
assert_eq!(session.access_token(), "at_sample_access_token");
assert_eq!(session.refresh_token(), Some("rt_sample_refresh_token"));
assert_eq!(session.token_type(), "DPoP");
assert_eq!(session.scope(), Some("atproto transition:generic"));
assert!(!session.is_expired());
assert_eq!(session.dpop_auth_header(), "DPoP at_sample_access_token");
}
#[test]
fn test_session_creation_case_insensitive_dpop() {
let key = DPoPKey::generate();
let session = OAuthSession::new(
"did:plc:alice123",
"at_123",
None,
"dpop",
None,
None,
key,
None,
None,
None,
)
.unwrap();
assert_eq!(session.token_type(), "dpop");
}
#[test]
fn test_session_creation_bearer_rejected() {
let key = DPoPKey::generate();
let res = OAuthSession::new(
"did:plc:alice123",
"at_123",
None,
"Bearer",
None,
None,
key,
None,
None,
None,
);
assert!(matches!(
res,
Err(AtprotoOAuthError::Token(TokenError::InvalidTokenType(_)))
));
}
#[test]
fn test_session_token_rotation() {
let key = DPoPKey::generate();
let mut session = OAuthSession::new(
"did:plc:alice123",
"at_initial",
Some("rt_initial".to_string()),
"DPoP",
None,
Some(300),
key,
None,
None,
None,
)
.unwrap();
assert_eq!(session.access_token(), "at_initial");
assert_eq!(session.refresh_token(), Some("rt_initial"));
session.rotate_tokens("at_rotated", Some("rt_rotated".to_string()), Some(600));
assert_eq!(session.access_token(), "at_rotated");
assert_eq!(session.refresh_token(), Some("rt_rotated"));
assert!(!session.is_expired());
}
#[test]
fn test_session_dpop_proof_generation_with_ath() {
let key = DPoPKey::generate();
let access_token = "at_alice_secret_token";
let session = OAuthSession::new(
"did:plc:alice123",
access_token,
None,
"DPoP",
None,
Some(3600),
key.clone(),
None,
None,
None,
)
.unwrap();
let htu = "https://pds.example.com/xrpc/app.bsky.actor.getProfile";
let proof = session.create_dpop_proof("GET", htu, None).unwrap();
let expected_ath = compute_access_token_hash(access_token);
let verifier = DPoPVerifier::new();
let (claims, jwk) = verifier
.verify_proof(&proof, "GET", htu, None, Some(&expected_ath), None)
.unwrap();
assert_eq!(claims.htm, "GET");
assert_eq!(claims.ath.as_deref(), Some(expected_ath.as_str()));
assert_eq!(jwk.thumbprint(), key.jwk_thumbprint());
}
}