josh_github_auth/
middleware.rs1use anyhow::Result;
2use base64::engine::Engine as _;
3use base64::engine::general_purpose::STANDARD as BASE64;
4use reqwest::header;
5use reqwest_middleware::{Middleware, Next};
6use secret_vault_value::SecretValue;
7use tokio::sync::{mpsc, oneshot};
8use tokio::time::Instant;
9
10use std::time::Duration;
11
12use crate::app_flow::GithubAppAuth;
13use crate::device_flow::{AccessTokenResponse, DeviceAuthFlow};
14
15struct TokenState {
16 access_token: String,
17 refresh_token: Option<String>,
18 expires_at: Option<Instant>,
19 flow: DeviceAuthFlow,
20}
21
22enum Command {
23 GetToken(oneshot::Sender<Result<String>>),
24}
25
26const EXPIRY_BUFFER: Duration = Duration::from_secs(30);
27
28async fn device_flow_actor_loop(mut state: TokenState, mut rx: mpsc::UnboundedReceiver<Command>) {
29 while let Some(cmd) = rx.recv().await {
30 match cmd {
31 Command::GetToken(reply) => {
32 let result = maybe_refresh_and_get_token(&mut state).await;
33 let _ = reply.send(result);
34 }
35 }
36 }
37}
38
39async fn maybe_refresh_and_get_token(state: &mut TokenState) -> Result<String> {
40 let needs_refresh = match (state.expires_at, &state.refresh_token) {
41 (Some(expires_at), Some(_)) => Instant::now() + EXPIRY_BUFFER >= expires_at,
42 _ => false,
43 };
44
45 if needs_refresh {
46 let refresh_token = state.refresh_token.as_deref().unwrap();
47 tracing::debug!("access token expired, refreshing");
48 let refreshed = state.flow.refresh_token(refresh_token).await?;
49 state.access_token = refreshed.access_token;
50 state.refresh_token = Some(refreshed.refresh_token);
51 state.expires_at = Some(Instant::now() + Duration::from_secs(refreshed.expires_in));
52 }
53
54 Ok(state.access_token.clone())
55}
56
57async fn app_flow_actor_loop(mut auth: GithubAppAuth, mut rx: mpsc::UnboundedReceiver<Command>) {
58 while let Some(Command::GetToken(reply)) = rx.recv().await {
59 let result = auth.get_or_refresh().await;
60 let _ = reply.send(result);
61 }
62}
63
64pub struct GithubAuthMiddleware {
65 sender: mpsc::UnboundedSender<Command>,
66}
67
68impl GithubAuthMiddleware {
69 pub fn from_environment(stored_token: Option<AccessTokenResponse>) -> Option<Self> {
75 if let Ok(token) = std::env::var(crate::GITHUB_USER_TOKEN_ENV)
76 && !token.is_empty()
77 {
78 tracing::info!("using GH_TOKEN for GitHub authentication");
79 return Some(Self::from_token(token));
80 }
81
82 let stored = stored_token?;
83 tracing::info!("using stored device-flow token for GitHub authentication");
84
85 Some(Self::from_app_flow(
86 stored,
87 crate::APP_CLIENT_ID.to_string(),
88 ))
89 }
90
91 pub fn from_app_flow(token: AccessTokenResponse, client_id: String) -> Self {
92 let (sender, receiver) = mpsc::unbounded_channel();
93
94 let expires_at = token
95 .expires_in
96 .map(|secs| Instant::now() + Duration::from_secs(secs));
97
98 let state = TokenState {
99 access_token: token.access_token,
100 refresh_token: token.refresh_token,
101 expires_at,
102 flow: DeviceAuthFlow::new(client_id),
103 };
104
105 tokio::spawn(device_flow_actor_loop(state, receiver));
106
107 Self { sender }
108 }
109
110 pub async fn from_github_app(
111 app_id: String,
112 installation_id: String,
113 key: SecretValue,
114 ) -> Result<Self> {
115 let auth = GithubAppAuth::authenticate(app_id, installation_id, key).await?;
116 let (sender, receiver) = mpsc::unbounded_channel();
117
118 tokio::spawn(app_flow_actor_loop(auth, receiver));
119
120 Ok(Self { sender })
121 }
122
123 pub fn from_token(token: impl Into<SecretValue>) -> Self {
124 let (sender, mut receiver) = mpsc::unbounded_channel();
125 let token = token.into();
126
127 tokio::spawn(async move {
128 while let Some(Command::GetToken(reply)) = receiver.recv().await {
129 let _ = reply.send(Ok(token.as_sensitive_str().to_string()));
130 }
131 });
132
133 Self { sender }
134 }
135
136 async fn get_token(&self) -> Result<String> {
137 let (tx, rx) = oneshot::channel();
138
139 self.sender
140 .send(Command::GetToken(tx))
141 .map_err(|_| anyhow::anyhow!("token actor dropped"))?;
142
143 rx.await
144 .map_err(|_| anyhow::anyhow!("token actor dropped"))?
145 }
146}
147
148#[async_trait::async_trait]
149impl Middleware for GithubAuthMiddleware {
150 async fn handle(
151 &self,
152 mut req: reqwest::Request,
153 extensions: &mut http::Extensions,
154 next: Next<'_>,
155 ) -> reqwest_middleware::Result<reqwest::Response> {
156 let token = self.get_token().await.map_err(|e| {
157 reqwest_middleware::Error::Middleware(anyhow::anyhow!(
158 "failed to get auth token: {}",
159 e
160 ))
161 })?;
162
163 req.headers_mut().insert(
164 header::AUTHORIZATION,
165 header::HeaderValue::from_str(&format!("Bearer {}", token))
166 .expect("token contains invalid header characters"),
167 );
168
169 next.run(req, extensions).await
170 }
171}
172
173#[async_trait::async_trait]
174impl josh_command_middleware::CommandMiddleware for GithubAuthMiddleware {
175 async fn apply(&self, cmd: &mut josh_command_middleware::Command) -> anyhow::Result<()> {
176 let token = self.get_token().await.map_err(|e| {
177 josh_command_middleware::Error::Middleware(anyhow::anyhow!(
178 "failed to get auth token: {}",
179 e
180 ))
181 })?;
182
183 if cmd.program_mut().as_str() != "git" {
184 return Err(anyhow::anyhow!(
185 "Can't attach auth to anything other than git"
186 ));
187 }
188
189 let credentials = BASE64.encode(format!("x-access-token:{}", token));
194 let header = format!("http.extraHeader=Authorization: Basic {}", credentials);
195
196 let args = cmd.args_mut();
197 args.insert(0, header);
198 args.insert(0, "-c".to_string());
199
200 Ok(())
201 }
202}