77//! provider credential; the runtime later exchanges it for a short-lived
88//! Copilot bearer (see `puffer-core/runtime/copilot.rs`).
99
10- use anyhow:: { bail, Context , Result } ;
10+ use anyhow:: { anyhow , bail, Context , Result } ;
1111use puffer_provider_registry:: COPILOT_USER_AGENT ;
1212use serde:: Deserialize ;
1313use std:: time:: Duration ;
@@ -76,6 +76,7 @@ pub(crate) fn start_device_flow() -> Result<DeviceFlowStart> {
7676}
7777
7878/// Outcome of a single poll of the device-flow token endpoint.
79+ #[ derive( Debug ) ]
7980pub ( crate ) enum DeviceFlowPoll {
8081 /// User has not authorized yet — keep polling.
8182 Pending ,
@@ -87,39 +88,32 @@ pub(crate) enum DeviceFlowPoll {
8788 Failed ( String ) ,
8889}
8990
90- /// Polls the token endpoint once with the device code.
91- pub ( crate ) fn poll_device_flow ( device_code : & str ) -> Result < DeviceFlowPoll > {
92- #[ derive( Deserialize ) ]
93- struct Resp {
94- #[ serde( default ) ]
95- access_token : Option < String > ,
96- #[ serde( default ) ]
97- error : Option < String > ,
91+ struct DevicePollHttpResponse {
92+ status : reqwest:: StatusCode ,
93+ body : String ,
94+ }
95+
96+ #[ derive( Deserialize ) ]
97+ struct DevicePollResponse {
98+ #[ serde( default ) ]
99+ access_token : Option < String > ,
100+ #[ serde( default ) ]
101+ error : Option < String > ,
102+ }
103+
104+ fn classify_device_flow_poll_response (
105+ response : Result < DevicePollHttpResponse > ,
106+ ) -> Result < DeviceFlowPoll > {
107+ let response = response?;
108+ if !response. status . is_success ( ) {
109+ bail ! (
110+ "GitHub device-flow token poll failed ({}): {}" ,
111+ response. status,
112+ response. body
113+ ) ;
98114 }
99- let client = http_client ( ) ?;
100- // A single poll must not abort the whole login on a transient blip. Network
101- // errors and non-JSON/unknown bodies (e.g. a 5xx HTML error page from an
102- // infra hiccup) are treated as Pending so the caller keeps polling until the
103- // device code genuinely expires; only GitHub's documented terminal device-
104- // flow errors end the flow.
105- let response = match client
106- . post ( ACCESS_TOKEN_URL )
107- . header ( "Accept" , "application/json" )
108- . header ( "User-Agent" , COPILOT_USER_AGENT )
109- . form ( & [
110- ( "client_id" , COPILOT_CLIENT_ID ) ,
111- ( "device_code" , device_code) ,
112- ( "grant_type" , "urn:ietf:params:oauth:grant-type:device_code" ) ,
113- ] )
114- . send ( )
115- {
116- Ok ( response) => response,
117- Err ( _) => return Ok ( DeviceFlowPoll :: Pending ) ,
118- } ;
119- let body = response. text ( ) . unwrap_or_default ( ) ;
120- let Ok ( parsed) = serde_json:: from_str :: < Resp > ( & body) else {
121- return Ok ( DeviceFlowPoll :: Pending ) ;
122- } ;
115+ let parsed: DevicePollResponse =
116+ serde_json:: from_str ( & response. body ) . context ( "parsing GitHub device-flow poll response" ) ?;
123117 if let Some ( token) = parsed. access_token . filter ( |t| !t. is_empty ( ) ) {
124118 return Ok ( DeviceFlowPoll :: Done ( token) ) ;
125119 }
@@ -136,7 +130,80 @@ pub(crate) fn poll_device_flow(device_code: &str) -> Result<DeviceFlowPoll> {
136130 | "incorrect_device_code"
137131 | "device_flow_disabled" ) ,
138132 ) => Ok ( DeviceFlowPoll :: Failed ( err. to_string ( ) ) ) ,
139- // Unknown error code — treat as transient rather than aborting.
133+ // Unknown GitHub error code — treat as transient rather than aborting.
140134 Some ( _) => Ok ( DeviceFlowPoll :: Pending ) ,
141135 }
142136}
137+
138+ /// Polls the token endpoint once with the device code.
139+ pub ( crate ) fn poll_device_flow ( device_code : & str ) -> Result < DeviceFlowPoll > {
140+ let client = http_client ( ) ?;
141+ // GitHub's device-flow protocol has explicit non-terminal states
142+ // (`authorization_pending`, `slow_down`). Transport failures are not one of
143+ // them: surface those as poll errors so desktop callers can use their
144+ // consecutive-error guard instead of waiting until device-code expiry.
145+ let response = match client
146+ . post ( ACCESS_TOKEN_URL )
147+ . header ( "Accept" , "application/json" )
148+ . header ( "User-Agent" , COPILOT_USER_AGENT )
149+ . form ( & [
150+ ( "client_id" , COPILOT_CLIENT_ID ) ,
151+ ( "device_code" , device_code) ,
152+ ( "grant_type" , "urn:ietf:params:oauth:grant-type:device_code" ) ,
153+ ] )
154+ . send ( )
155+ {
156+ Ok ( response) => {
157+ let status = response. status ( ) ;
158+ let body = response. text ( ) . unwrap_or_default ( ) ;
159+ Ok ( DevicePollHttpResponse { status, body } )
160+ }
161+ Err ( error) => Err ( anyhow ! ( "GitHub device-flow poll network error: {error}" ) ) ,
162+ } ;
163+ classify_device_flow_poll_response ( response)
164+ }
165+
166+ #[ cfg( test) ]
167+ mod tests {
168+ use super :: * ;
169+ use anyhow:: anyhow;
170+ use reqwest:: StatusCode ;
171+
172+ fn classify_ok ( body : & str ) -> Result < DeviceFlowPoll > {
173+ classify_device_flow_poll_response ( Ok ( DevicePollHttpResponse {
174+ status : StatusCode :: OK ,
175+ body : body. to_string ( ) ,
176+ } ) )
177+ }
178+
179+ #[ test]
180+ fn authorization_pending_remains_pending ( ) {
181+ let result = classify_ok ( r#"{"error":"authorization_pending"}"# ) . unwrap ( ) ;
182+ assert ! ( matches!( result, DeviceFlowPoll :: Pending ) ) ;
183+ }
184+
185+ #[ test]
186+ fn slow_down_remains_slow_down ( ) {
187+ let result = classify_ok ( r#"{"error":"slow_down"}"# ) . unwrap ( ) ;
188+ assert ! ( matches!( result, DeviceFlowPoll :: SlowDown ) ) ;
189+ }
190+
191+ #[ test]
192+ fn transport_errors_are_not_mapped_to_pending ( ) {
193+ let error = classify_device_flow_poll_response ( Err ( anyhow ! ( "connection refused" ) ) )
194+ . expect_err ( "transport failures must reject the poll RPC" ) ;
195+ assert ! ( error. to_string( ) . contains( "connection refused" ) ) ;
196+ }
197+
198+ #[ test]
199+ fn malformed_poll_response_is_not_mapped_to_pending ( ) {
200+ let error = classify_ok ( "<html>bad gateway</html>" )
201+ . expect_err ( "malformed poll responses are not protocol pending states" ) ;
202+ assert ! (
203+ error
204+ . to_string( )
205+ . contains( "parsing GitHub device-flow poll response" ) ,
206+ "{error:#}"
207+ ) ;
208+ }
209+ }
0 commit comments