@@ -8703,3 +8703,153 @@ fn validate_webhook_token(headers: &axum::http::HeaderMap, token_env: &str) -> b
87038703 }
87048704 provided. as_bytes ( ) . ct_eq ( expected. as_bytes ( ) ) . into ( )
87058705}
8706+
8707+ // ══════════════════════════════════════════════════════════════════════
8708+ // GitHub Copilot OAuth Device Flow
8709+ // ══════════════════════════════════════════════════════════════════════
8710+
8711+ /// State for an in-progress device flow.
8712+ struct CopilotFlowState {
8713+ device_code : String ,
8714+ interval : u64 ,
8715+ expires_at : Instant ,
8716+ }
8717+
8718+ /// Active device flows, keyed by poll_id. Auto-expire after the flow's TTL.
8719+ static COPILOT_FLOWS : LazyLock < DashMap < String , CopilotFlowState > > = LazyLock :: new ( DashMap :: new) ;
8720+
8721+ /// POST /api/providers/github-copilot/oauth/start
8722+ ///
8723+ /// Initiates a GitHub device flow for Copilot authentication.
8724+ /// Returns a user code and verification URI that the user visits in their browser.
8725+ pub async fn copilot_oauth_start ( ) -> impl IntoResponse {
8726+ // Clean up expired flows first
8727+ COPILOT_FLOWS . retain ( |_, state| state. expires_at > Instant :: now ( ) ) ;
8728+
8729+ match openfang_runtime:: copilot_oauth:: start_device_flow ( ) . await {
8730+ Ok ( resp) => {
8731+ let poll_id = uuid:: Uuid :: new_v4 ( ) . to_string ( ) ;
8732+
8733+ COPILOT_FLOWS . insert (
8734+ poll_id. clone ( ) ,
8735+ CopilotFlowState {
8736+ device_code : resp. device_code ,
8737+ interval : resp. interval ,
8738+ expires_at : Instant :: now ( )
8739+ + std:: time:: Duration :: from_secs ( resp. expires_in ) ,
8740+ } ,
8741+ ) ;
8742+
8743+ (
8744+ StatusCode :: OK ,
8745+ Json ( serde_json:: json!( {
8746+ "user_code" : resp. user_code,
8747+ "verification_uri" : resp. verification_uri,
8748+ "poll_id" : poll_id,
8749+ "expires_in" : resp. expires_in,
8750+ "interval" : resp. interval,
8751+ } ) ) ,
8752+ )
8753+ }
8754+ Err ( e) => (
8755+ StatusCode :: INTERNAL_SERVER_ERROR ,
8756+ Json ( serde_json:: json!( { "error" : e } ) ) ,
8757+ ) ,
8758+ }
8759+ }
8760+
8761+ /// GET /api/providers/github-copilot/oauth/poll/{poll_id}
8762+ ///
8763+ /// Poll the status of a GitHub device flow.
8764+ /// Returns `pending`, `complete`, `expired`, `denied`, or `error`.
8765+ /// On `complete`, saves the token to secrets.env and sets GITHUB_TOKEN.
8766+ pub async fn copilot_oauth_poll (
8767+ State ( state) : State < Arc < AppState > > ,
8768+ Path ( poll_id) : Path < String > ,
8769+ ) -> impl IntoResponse {
8770+ let flow = match COPILOT_FLOWS . get ( & poll_id) {
8771+ Some ( f) => f,
8772+ None => {
8773+ return (
8774+ StatusCode :: NOT_FOUND ,
8775+ Json ( serde_json:: json!( { "status" : "not_found" , "error" : "Unknown poll_id" } ) ) ,
8776+ )
8777+ }
8778+ } ;
8779+
8780+ if flow. expires_at <= Instant :: now ( ) {
8781+ drop ( flow) ;
8782+ COPILOT_FLOWS . remove ( & poll_id) ;
8783+ return (
8784+ StatusCode :: OK ,
8785+ Json ( serde_json:: json!( { "status" : "expired" } ) ) ,
8786+ ) ;
8787+ }
8788+
8789+ let device_code = flow. device_code . clone ( ) ;
8790+ drop ( flow) ;
8791+
8792+ match openfang_runtime:: copilot_oauth:: poll_device_flow ( & device_code) . await {
8793+ openfang_runtime:: copilot_oauth:: DeviceFlowStatus :: Pending => (
8794+ StatusCode :: OK ,
8795+ Json ( serde_json:: json!( { "status" : "pending" } ) ) ,
8796+ ) ,
8797+ openfang_runtime:: copilot_oauth:: DeviceFlowStatus :: Complete { access_token } => {
8798+ // Save to secrets.env
8799+ let secrets_path = state. kernel . config . home_dir . join ( "secrets.env" ) ;
8800+ if let Err ( e) = write_secret_env ( & secrets_path, "GITHUB_TOKEN" , & access_token) {
8801+ return (
8802+ StatusCode :: INTERNAL_SERVER_ERROR ,
8803+ Json ( serde_json:: json!( { "status" : "error" , "error" : format!( "Failed to save token: {e}" ) } ) ) ,
8804+ ) ;
8805+ }
8806+
8807+ // Set in current process
8808+ std:: env:: set_var ( "GITHUB_TOKEN" , access_token. as_str ( ) ) ;
8809+
8810+ // Refresh auth detection
8811+ state
8812+ . kernel
8813+ . model_catalog
8814+ . write ( )
8815+ . unwrap_or_else ( |e| e. into_inner ( ) )
8816+ . detect_auth ( ) ;
8817+
8818+ // Clean up flow state
8819+ COPILOT_FLOWS . remove ( & poll_id) ;
8820+
8821+ (
8822+ StatusCode :: OK ,
8823+ Json ( serde_json:: json!( { "status" : "complete" } ) ) ,
8824+ )
8825+ }
8826+ openfang_runtime:: copilot_oauth:: DeviceFlowStatus :: SlowDown { new_interval } => {
8827+ // Update interval
8828+ if let Some ( mut f) = COPILOT_FLOWS . get_mut ( & poll_id) {
8829+ f. interval = new_interval;
8830+ }
8831+ (
8832+ StatusCode :: OK ,
8833+ Json ( serde_json:: json!( { "status" : "pending" , "interval" : new_interval} ) ) ,
8834+ )
8835+ }
8836+ openfang_runtime:: copilot_oauth:: DeviceFlowStatus :: Expired => {
8837+ COPILOT_FLOWS . remove ( & poll_id) ;
8838+ (
8839+ StatusCode :: OK ,
8840+ Json ( serde_json:: json!( { "status" : "expired" } ) ) ,
8841+ )
8842+ }
8843+ openfang_runtime:: copilot_oauth:: DeviceFlowStatus :: AccessDenied => {
8844+ COPILOT_FLOWS . remove ( & poll_id) ;
8845+ (
8846+ StatusCode :: OK ,
8847+ Json ( serde_json:: json!( { "status" : "denied" } ) ) ,
8848+ )
8849+ }
8850+ openfang_runtime:: copilot_oauth:: DeviceFlowStatus :: Error ( e) => (
8851+ StatusCode :: OK ,
8852+ Json ( serde_json:: json!( { "status" : "error" , "error" : e} ) ) ,
8853+ ) ,
8854+ }
8855+ }
0 commit comments