Skip to main content

josh_github_auth/
middleware.rs

1use 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    /// Resolve auth credentials from the environment.
70    ///
71    /// Prefers the `GH_TOKEN` env var; otherwise falls back to the supplied
72    /// stored device-flow token (read by the caller to prevent circular deps).
73    /// Returns `None` if neither is available.
74    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        // Git-over-HTTPS to GitHub authenticates via HTTP Basic auth, with the
190        // token supplied as the password (the username is ignored by GitHub, but
191        // `x-access-token` is the conventional value for app/installation tokens).
192        // The REST/GraphQL API uses `Bearer`, but git does not accept it.
193        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}