Skip to main content

github_copilot_sdk/
github_token.rs

1//! Session-scoped GitHub token provider callbacks.
2
3use std::collections::HashMap;
4use std::future::Future;
5use std::sync::{Arc, OnceLock, Weak};
6
7use async_trait::async_trait;
8use parking_lot::Mutex;
9use serde_json::Value;
10
11use crate::generated::api_types::{
12    GitHubTokenAcquireReason, GitHubTokenAcquireRequest, GitHubTokenAcquireResult,
13    GitHubTokenAcquireResultCancelled, GitHubTokenAcquireResultToken,
14};
15use crate::{Client, ClientInner, JsonRpcError, JsonRpcRequest, JsonRpcResponse, error_codes};
16
17/// Why the runtime is requesting a GitHub token.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum GitHubTokenRequestReason {
20    /// The session needs its initial token.
21    Initial,
22    /// The session needs a refreshed token.
23    Refresh,
24}
25
26/// Context supplied when the runtime needs a GitHub token for a session.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct GitHubTokenProviderArgs {
29    /// Effective GitHub host for which a token is required.
30    pub host: String,
31    /// Session receiving the token, when the runtime has assigned its ID.
32    pub session_id: Option<crate::SessionId>,
33    /// Whether this is the initial token acquisition or a refresh.
34    pub reason: GitHubTokenRequestReason,
35}
36
37/// A GitHub access token returned by a session token provider.
38///
39/// `expires_in_seconds` is the positive remaining lifetime when the callback
40/// completes. Production GitHub tokens typically last eight hours.
41pub struct GitHubToken {
42    access_token: String,
43    expires_in_seconds: i64,
44    token_type: Option<String>,
45}
46
47impl GitHubToken {
48    /// Construct a token response with its remaining lifetime in seconds.
49    pub fn new(access_token: impl Into<String>, expires_in_seconds: i64) -> Self {
50        Self {
51            access_token: access_token.into(),
52            expires_in_seconds,
53            token_type: None,
54        }
55    }
56
57    /// Override the OAuth token type. The runtime defaults to `bearer` when unset.
58    pub fn with_token_type(mut self, token_type: impl Into<String>) -> Self {
59        self.token_type = Some(token_type.into());
60        self
61    }
62
63    fn into_wire(self) -> GitHubTokenAcquireResultToken {
64        GitHubTokenAcquireResultToken {
65            access_token: self.access_token,
66            expires_in: self.expires_in_seconds,
67            kind: Default::default(),
68            token_type: self.token_type,
69        }
70    }
71}
72
73impl std::fmt::Debug for GitHubToken {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        f.debug_struct("GitHubToken")
76            .field("access_token", &"<redacted>")
77            .field("expires_in_seconds", &self.expires_in_seconds)
78            .field("token_type", &self.token_type)
79            .finish()
80    }
81}
82
83/// Result of acquiring a session-scoped GitHub token.
84pub enum GitHubTokenProviderResult {
85    /// A token was acquired.
86    Token(GitHubToken),
87    /// The host cancelled acquisition.
88    Cancelled,
89}
90
91impl std::fmt::Debug for GitHubTokenProviderResult {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        match self {
94            Self::Token(token) => f.debug_tuple("Token").field(token).finish(),
95            Self::Cancelled => f.write_str("Cancelled"),
96        }
97    }
98}
99
100/// Async callback used to acquire GitHub tokens for one session.
101#[async_trait]
102pub trait GitHubTokenProvider: Send + Sync {
103    /// Acquire a token or explicitly cancel the request.
104    ///
105    /// Initial cancellation, errors, and invalid token responses reject session
106    /// creation or resume instead of falling back to ambient authentication.
107    async fn get_token(
108        &self,
109        args: GitHubTokenProviderArgs,
110    ) -> Result<GitHubTokenProviderResult, crate::Error>;
111}
112
113#[async_trait]
114impl<F, Fut> GitHubTokenProvider for F
115where
116    F: Fn(GitHubTokenProviderArgs) -> Fut + Send + Sync,
117    Fut: Future<Output = Result<GitHubTokenProviderResult, crate::Error>> + Send,
118{
119    async fn get_token(
120        &self,
121        args: GitHubTokenProviderArgs,
122    ) -> Result<GitHubTokenProviderResult, crate::Error> {
123        (self)(args).await
124    }
125}
126
127#[derive(Default)]
128struct RegistryState {
129    providers: HashMap<String, Arc<dyn GitHubTokenProvider>>,
130    session_owners: HashMap<crate::SessionId, String>,
131}
132
133pub(crate) struct GitHubTokenRegistry {
134    state: Mutex<RegistryState>,
135    client: OnceLock<Weak<ClientInner>>,
136}
137
138impl GitHubTokenRegistry {
139    pub(crate) fn new() -> Self {
140        Self {
141            state: Mutex::new(RegistryState::default()),
142            client: OnceLock::new(),
143        }
144    }
145
146    pub(crate) fn set_client(&self, client: Weak<ClientInner>) {
147        let _ = self.client.set(client);
148    }
149
150    pub(crate) fn register(&self, provider: Arc<dyn GitHubTokenProvider>) -> String {
151        let registration_id = uuid::Uuid::new_v4().to_string();
152        self.state
153            .lock()
154            .providers
155            .insert(registration_id.clone(), provider);
156        registration_id
157    }
158
159    pub(crate) fn claim(&self, registration_id: &str, session_id: crate::SessionId) {
160        let mut state = self.state.lock();
161        if let Some(previous) = state
162            .session_owners
163            .insert(session_id, registration_id.to_string())
164            && previous != registration_id
165        {
166            state.providers.remove(&previous);
167        }
168    }
169
170    pub(crate) fn unregister(&self, registration_id: &str) {
171        let mut state = self.state.lock();
172        state.providers.remove(registration_id);
173        state
174            .session_owners
175            .retain(|_, owned| owned != registration_id);
176    }
177
178    pub(crate) fn retire_session(&self, session_id: &crate::SessionId) {
179        let mut state = self.state.lock();
180        if let Some(registration_id) = state.session_owners.remove(session_id) {
181            state.providers.remove(&registration_id);
182        }
183    }
184
185    pub(crate) fn clear(&self) {
186        let mut state = self.state.lock();
187        state.providers.clear();
188        state.session_owners.clear();
189    }
190
191    pub(crate) async fn dispatch(&self, request: JsonRpcRequest) {
192        let Some(inner) = self.client.get().and_then(Weak::upgrade) else {
193            return;
194        };
195        let client = Client::from_inner(inner);
196        let params = request
197            .params
198            .clone()
199            .unwrap_or(Value::Object(serde_json::Map::new()));
200        let params: GitHubTokenAcquireRequest = match serde_json::from_value(params) {
201            Ok(params) => params,
202            Err(error) => {
203                send_error(
204                    &client,
205                    request.id,
206                    error_codes::INVALID_PARAMS,
207                    &format!("invalid params: {error}"),
208                )
209                .await;
210                return;
211            }
212        };
213        let provider = self
214            .state
215            .lock()
216            .providers
217            .get(&params.registration_id)
218            .cloned();
219        let Some(provider) = provider else {
220            send_error(
221                &client,
222                request.id,
223                error_codes::INTERNAL_ERROR,
224                "unknown GitHub token provider registration",
225            )
226            .await;
227            return;
228        };
229
230        let reason = match params.reason {
231            GitHubTokenAcquireReason::Initial => GitHubTokenRequestReason::Initial,
232            GitHubTokenAcquireReason::Refresh => GitHubTokenRequestReason::Refresh,
233            GitHubTokenAcquireReason::Unknown => {
234                send_error(
235                    &client,
236                    request.id,
237                    error_codes::INVALID_PARAMS,
238                    "unknown GitHub token acquisition reason",
239                )
240                .await;
241                return;
242            }
243        };
244
245        match provider
246            .get_token(GitHubTokenProviderArgs {
247                host: params.host,
248                session_id: params.session_id,
249                reason,
250            })
251            .await
252        {
253            Ok(GitHubTokenProviderResult::Token(token)) => {
254                respond(
255                    &client,
256                    request.id,
257                    GitHubTokenAcquireResult::Token(token.into_wire()),
258                )
259                .await;
260            }
261            Ok(GitHubTokenProviderResult::Cancelled) => {
262                respond(
263                    &client,
264                    request.id,
265                    GitHubTokenAcquireResult::Cancelled(GitHubTokenAcquireResultCancelled {
266                        kind: Default::default(),
267                    }),
268                )
269                .await;
270            }
271            Err(error) => {
272                send_error(
273                    &client,
274                    request.id,
275                    error_codes::INTERNAL_ERROR,
276                    &format!("GitHub token provider failed: {error}"),
277                )
278                .await;
279            }
280        }
281    }
282}
283
284pub(crate) struct GitHubTokenRegistration {
285    registry: Arc<GitHubTokenRegistry>,
286    id: String,
287}
288
289impl GitHubTokenRegistration {
290    pub(crate) fn new(registry: Arc<GitHubTokenRegistry>, id: String) -> Self {
291        Self { registry, id }
292    }
293
294    pub(crate) fn id(&self) -> &str {
295        &self.id
296    }
297
298    pub(crate) fn claim(&self, session_id: crate::SessionId) {
299        self.registry.claim(&self.id, session_id);
300    }
301}
302
303impl Drop for GitHubTokenRegistration {
304    fn drop(&mut self) {
305        self.registry.unregister(&self.id);
306    }
307}
308
309async fn respond(client: &Client, request_id: u64, result: GitHubTokenAcquireResult) {
310    match serde_json::to_value(result) {
311        Ok(result) => {
312            let _ = client
313                .send_response(&JsonRpcResponse {
314                    jsonrpc: "2.0".to_string(),
315                    id: request_id,
316                    result: Some(result),
317                    error: None,
318                })
319                .await;
320        }
321        Err(_) => {
322            send_error(
323                client,
324                request_id,
325                error_codes::INTERNAL_ERROR,
326                "serialization failure",
327            )
328            .await;
329        }
330    }
331}
332
333async fn send_error(client: &Client, request_id: u64, code: i32, message: &str) {
334    let _ = client
335        .send_response(&JsonRpcResponse {
336            jsonrpc: "2.0".to_string(),
337            id: request_id,
338            result: None,
339            error: Some(JsonRpcError {
340                code,
341                message: message.to_string(),
342                data: None,
343            }),
344        })
345        .await;
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    #[test]
353    fn token_debug_is_redacted() {
354        let token = GitHubToken::new("do-not-print", 28_800);
355        assert!(!format!("{token:?}").contains("do-not-print"));
356    }
357
358    #[test]
359    fn retiring_session_removes_its_provider() {
360        let registry = GitHubTokenRegistry::new();
361        let provider = Arc::new(|_args: GitHubTokenProviderArgs| async {
362            Ok(GitHubTokenProviderResult::Cancelled)
363        });
364        let registration_id = registry.register(provider);
365        let session_id = crate::SessionId::from("session-1");
366        registry.claim(&registration_id, session_id.clone());
367
368        registry.retire_session(&session_id);
369
370        assert!(
371            !registry
372                .state
373                .lock()
374                .providers
375                .contains_key(&registration_id)
376        );
377    }
378}