Skip to content

Commit

Permalink
feat(term): refresh google id token periodically
Browse files Browse the repository at this point in the history
close #32
  • Loading branch information
ymgyt committed May 3, 2024
1 parent f107520 commit b5e0ae1
Show file tree
Hide file tree
Showing 10 changed files with 202 additions and 58 deletions.
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.

2 changes: 1 addition & 1 deletion crates/synd_term/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ synd-feed = { path = "../synd_feed", version = "0.3.1" }
synd-o11y = { path = "../synd_o11y", version = "0.1.5" }

anyhow = { workspace = true }
chrono = { workspace = true, features = ["std", "now"] }
chrono = { workspace = true, features = ["std", "now", "serde"] }
clap = { workspace = true, features = ["derive", "string", "color", "suggestions", "wrap_help", "env", "std"] }
crossterm = { version = "0.27.0", features = ["event-stream"] }
directories = "5.0.1"
Expand Down
25 changes: 25 additions & 0 deletions crates/synd_term/src/application/authenticator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ use synd_auth::{
jwt,
};

use crate::auth::{Credential, CredentialError};

pub struct DeviceFlows {
pub github: DeviceFlow<provider::Github>,
pub google: DeviceFlow<provider::Google>,
}

#[derive(Clone)]
pub struct JwtService {
pub google: jwt::google::JwtService,
}
Expand All @@ -18,6 +21,28 @@ impl JwtService {
google: jwt::google::JwtService::default(),
}
}

pub async fn refresh_google_id_token(
&self,
refresh_token: &str,
) -> Result<Credential, CredentialError> {
let id_token = self
.google
.refresh_id_token(refresh_token)
.await
.map_err(CredentialError::RefreshJwt)?;
let expired_at = self
.google
.decode_id_token_insecure(&id_token, false)
.map_err(CredentialError::DecodeJwt)?
.expired_at();
let credential = Credential::Google {
id_token,
refresh_token: refresh_token.to_owned(),
expired_at,
};
Ok(credential)
}
}

pub struct Authenticator {
Expand Down
13 changes: 13 additions & 0 deletions crates/synd_term/src/application/clock.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
use chrono::{DateTime, Utc};

pub trait Clock {
fn now(&self) -> DateTime<Utc>;
}

pub struct SystemClock;

impl Clock for SystemClock {
fn now(&self) -> DateTime<Utc> {
Utc::now()
}
}
105 changes: 91 additions & 14 deletions crates/synd_term/src/application/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
use std::{future, pin::Pin, time::Duration};
use std::{
future,
ops::{Add, Sub},
pin::Pin,
time::Duration,
};

use chrono::{DateTime, Utc};
use crossterm::event::{Event as CrosstermEvent, KeyEvent, KeyEventKind};
use futures_util::{FutureExt, Stream, StreamExt};
use ratatui::{style::palette::tailwind, widgets::Widget};
Expand All @@ -11,7 +17,7 @@ use tokio::time::{Instant, Sleep};

use crate::{
application::event::KeyEventResult,
auth::{AuthenticationProvider, Credential},
auth::{self, AuthenticationProvider, Credential},
client::{mutation::subscribe_feed::SubscribeFeedInput, Client},
command::Command,
config::{self, Categories},
Expand Down Expand Up @@ -42,6 +48,9 @@ use input_parser::InputParser;
mod authenticator;
pub use authenticator::{Authenticator, DeviceFlows, JwtService};

mod clock;
pub use clock::{Clock, SystemClock};

pub(crate) mod event;

enum Screen {
Expand Down Expand Up @@ -75,6 +84,7 @@ impl Default for Config {
}

pub struct Application {
clock: Box<dyn Clock>,
terminal: Terminal,
client: Client,
jobs: Jobs,
Expand Down Expand Up @@ -112,6 +122,7 @@ impl Application {
key_handlers.push(event::KeyHandler::Keymaps(keymaps));

Self {
clock: Box::new(SystemClock),
terminal,
client,
jobs: Jobs::new(),
Expand Down Expand Up @@ -143,25 +154,42 @@ impl Application {
}
}

pub fn jwt_service(&self) -> &JwtService {
fn now(&self) -> DateTime<Utc> {
self.clock.now()
}

fn jwt_service(&self) -> &JwtService {
&self.authenticator.jwt_service
}

pub fn jobs_mut(&mut self) -> &mut Jobs {
&mut self.jobs
}

fn keymaps(&mut self) -> &mut Keymaps {
self.key_handlers.keymaps_mut().unwrap()
}

pub fn set_credential(&mut self, cred: Credential) {
self.client.set_credential(cred);
pub async fn restore_credential(&self) -> Option<Credential> {
auth::credential_from_cache(self.jwt_service(), self.now()).await
}

pub fn handle_initial_credential(&mut self, cred: Credential) {
self.set_credential(cred);
self.initial_fetch();
self.components.auth.authenticated();
self.keymaps().disable(KeymapId::Login);
self.keymaps().enable(KeymapId::Tabs);
self.keymaps().enable(KeymapId::Entries);
self.keymaps().enable(KeymapId::Filter);
self.initial_fetch();
self.screen = Screen::Browse;
self.should_render = true;
self.reset_idle_timer();
self.should_render = true;
}

fn set_credential(&mut self, cred: Credential) {
self.schedule_credential_refreshing(&cred);
self.client.set_credential(cred);
}

fn initial_fetch(&mut self) {
Expand Down Expand Up @@ -309,6 +337,9 @@ impl Application {
} => {
self.complete_device_authroize_flow(provider, device_access_token);
}
Command::RefreshCredential { credential } => {
self.set_credential(credential);
}
Command::MoveTabSelection(direction) => {
self.keymaps().toggle(KeymapId::Entries);
self.keymaps().toggle(KeymapId::Subscription);
Expand Down Expand Up @@ -794,12 +825,25 @@ impl Application {
AuthenticationProvider::Github => Credential::Github {
access_token: device_access_token.access_token,
},
AuthenticationProvider::Google => Credential::Google {
id_token: device_access_token.id_token.expect("id token not found"),
refresh_token: device_access_token
.refresh_token
.expect("refresh token not found"),
},
AuthenticationProvider::Google => {
let id_token = device_access_token.id_token.expect("id token not found");
let expired_at = self
.jwt_service()
.google
.decode_id_token_insecure(&id_token, false)
.ok()
.map_or(
self.now().add(config::credential::FALLBACK_EXPIRE),
|claims| claims.expired_at(),
);
Credential::Google {
id_token,
refresh_token: device_access_token
.refresh_token
.expect("refresh token not found"),
expired_at,
}
}
};

// should test with tmp file?
Expand All @@ -810,7 +854,40 @@ impl Application {
}
}

self.set_credential(auth);
self.handle_initial_credential(auth);
}

fn schedule_credential_refreshing(&mut self, cred: &Credential) {
match cred {
Credential::Github { .. } => {}
Credential::Google {
refresh_token,
expired_at,
..
} => {
let until_expire = expired_at
.sub(config::credential::EXPIRE_MARGIN)
.sub(self.now())
.to_std()
.unwrap_or(config::credential::FALLBACK_EXPIRE);
let jwt_service = self.jwt_service().clone();
let refresh_token = refresh_token.clone();
let fut = async move {
tokio::time::sleep(until_expire).await;

tracing::debug!("Refresh google credential");
match jwt_service.refresh_google_id_token(&refresh_token).await {
Ok(credential) => Ok(Command::RefreshCredential { credential }),
Err(err) => Ok(Command::HandleError {
message: err.to_string(),
request_seq: None,
}),
}
}
.boxed();
self.jobs.futures.push(fut);
}
}
}
}

Expand Down
66 changes: 41 additions & 25 deletions crates/synd_term/src/auth/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
use std::path::{Path, PathBuf};

use chrono::Utc;
use std::{
cmp::Ordering,
fmt,
ops::Sub,
path::{Path, PathBuf},
};

use chrono::{DateTime, Utc};
use futures_util::TryFutureExt;
use serde::{Deserialize, Serialize};
use synd_auth::jwt::google::JwtError;
Expand Down Expand Up @@ -41,13 +46,21 @@ pub enum Credential {
Google {
id_token: String,
refresh_token: String,
expired_at: DateTime<Utc>,
},
}

impl fmt::Debug for Credential {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Credential").finish_non_exhaustive()
}
}

impl Credential {
async fn restore_from_path(
path: &Path,
jwt_service: &JwtService,
now: DateTime<Utc>,
) -> Result<Self, CredentialError> {
debug!(
path = path.display().to_string(),
Expand All @@ -61,6 +74,7 @@ impl Credential {
Credential::Google {
id_token,
refresh_token,
..
} => {
let claims = jwt_service
.google
Expand All @@ -69,27 +83,26 @@ impl Credential {
if !claims.email_verified {
return Err(CredentialError::GoogleJwtEmailNotVerified);
}
if !claims.is_expired(Utc::now()) {
return Ok(credential);
}

debug!("Google jwt expired, trying to refresh");

let id_token = jwt_service
.google
.refresh_id_token(refresh_token)
.await
.map_err(CredentialError::RefreshJwt)?;

let credential = Credential::Google {
id_token,
refresh_token: refresh_token.clone(),
let credential = match claims
.expired_at()
.sub(config::credential::EXPIRE_MARGIN)
.cmp(&now)
{
// expired
Ordering::Less | Ordering::Equal => {
debug!("Google jwt expired, trying to refresh");

let credential = jwt_service.refresh_google_id_token(refresh_token).await?;

persist_credential(&credential)
.map_err(CredentialError::PersistCredential)?;

debug!("Persist refreshed credential");
credential
}
// not expired
Ordering::Greater => credential,
};

persist_credential(&credential).map_err(CredentialError::PersistCredential)?;

debug!("Persist refreshed credential");

Ok(credential)
}
}
Expand All @@ -114,8 +127,11 @@ fn cred_file() -> PathBuf {
config::cache_dir().join("credential.json")
}

pub async fn credential_from_cache(jwt_service: &JwtService) -> Option<Credential> {
Credential::restore_from_path(cred_file().as_path(), jwt_service)
pub async fn credential_from_cache(
jwt_service: &JwtService,
now: DateTime<Utc>,
) -> Option<Credential> {
Credential::restore_from_path(cred_file().as_path(), jwt_service, now)
.inspect_err(|err| {
tracing::error!("Restore credential from cache: {err}");
})
Expand Down
9 changes: 7 additions & 2 deletions crates/synd_term/src/cli/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ use schemars::JsonSchema;
use serde::Serialize;
use url::Url;

use crate::{application::JwtService, auth, client::Client, types::ExportedFeed};
use crate::{
application::{Clock, JwtService, SystemClock},
auth,
client::Client,
types::ExportedFeed,
};

#[derive(Serialize, JsonSchema)]
struct Export {
Expand Down Expand Up @@ -51,7 +56,7 @@ impl ExportCommand {
let mut client = Client::new(endpoint, Duration::from_secs(10))?;
let jwt_service = JwtService::new();

let credentials = auth::credential_from_cache(&jwt_service)
let credentials = auth::credential_from_cache(&jwt_service, SystemClock.now())
.await
.ok_or_else(|| anyhow!("You are not authenticated, try login in first"))?;
client.set_credential(credentials);
Expand Down

0 comments on commit b5e0ae1

Please sign in to comment.