@@ -85,22 +85,15 @@ export function isTokenExpired(tokens: TokenData): boolean {
8585}
8686
8787async function refreshAccessToken ( refreshToken : string ) : Promise < TokenData > {
88- const response = await fetch ( `${ getBaseUrl ( ) } /api/oauth/token` , {
89- method : "POST" ,
90- headers : { "Content-Type" : "application/x-www-form-urlencoded" } ,
91- body : new URLSearchParams ( {
88+ return oauthRequest < TokenData > (
89+ `${ getBaseUrl ( ) } /api/oauth/token` ,
90+ new URLSearchParams ( {
9291 grant_type : "refresh_token" ,
9392 client_id : CLI_CLIENT_ID ,
9493 refresh_token : refreshToken ,
95- } ) . toString ( ) ,
96- } ) ;
97-
98- if ( ! response . ok ) {
99- const err = ( await response . json ( ) . catch ( ( ) => ( { } ) ) ) as TokenErrorResponse ;
100- throw new Error ( err . error_description || err . error || "Failed to refresh token" ) ;
101- }
102-
103- return ( await response . json ( ) ) as TokenData ;
94+ } ) ,
95+ "Failed to refresh token"
96+ ) ;
10497}
10598
10699/**
@@ -147,6 +140,86 @@ export interface DeviceAuthorizationResponse {
147140
148141const DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code" ;
149142
143+ async function describeErrorResponse ( response : Response , fallback : string ) : Promise < string > {
144+ const body = await response . text ( ) . catch ( ( ) => "" ) ;
145+
146+ try {
147+ const err = JSON . parse ( body ) as TokenErrorResponse ;
148+ const message = err . error_description || err . error ;
149+ if ( message ) return message ;
150+ } catch {
151+ // An interceptor's HTML, not an OAuth error object.
152+ }
153+
154+ const excerpt = body . replace ( / \s + / g, " " ) . trim ( ) . slice ( 0 , 200 ) ;
155+ const detail = `HTTP ${ response . status } from ${ response . url } ` ;
156+ return excerpt ? `${ fallback } (${ detail } ): ${ excerpt } ` : `${ fallback } (${ detail } )` ;
157+ }
158+
159+ const TLS_HINT =
160+ "The TLS certificate could not be verified, which usually means a proxy is inspecting HTTPS traffic. Point NODE_EXTRA_CA_CERTS at your organization's root CA." ;
161+ const DNS_HINT = "DNS lookup failed. Check your network or VPN connection." ;
162+ const BLOCKED_HINT =
163+ "The connection was refused or reset, which usually means a firewall or proxy is blocking it." ;
164+ const TIMEOUT_HINT = "The connection timed out. A proxy or firewall may be dropping the request." ;
165+ const DEFAULT_HINT =
166+ "If you are behind a corporate proxy, note that Node does not use HTTPS_PROXY automatically." ;
167+
168+ const CONNECTION_HINTS : Record < string , string > = {
169+ UNABLE_TO_VERIFY_LEAF_SIGNATURE : TLS_HINT ,
170+ SELF_SIGNED_CERT_IN_CHAIN : TLS_HINT ,
171+ DEPTH_ZERO_SELF_SIGNED_CERT : TLS_HINT ,
172+ CERT_HAS_EXPIRED : TLS_HINT ,
173+ ENOTFOUND : DNS_HINT ,
174+ EAI_AGAIN : DNS_HINT ,
175+ ECONNREFUSED : BLOCKED_HINT ,
176+ ECONNRESET : BLOCKED_HINT ,
177+ EHOSTUNREACH : BLOCKED_HINT ,
178+ ENETUNREACH : BLOCKED_HINT ,
179+ UND_ERR_CONNECT_TIMEOUT : TIMEOUT_HINT ,
180+ ETIMEDOUT : TIMEOUT_HINT ,
181+ } ;
182+
183+ function getErrorCause ( error : unknown ) : { code ?: string ; message ?: string } {
184+ if ( typeof error !== "object" || error === null || ! ( "cause" in error ) ) return { } ;
185+ const cause = ( error as { cause : unknown } ) . cause ;
186+ if ( typeof cause !== "object" || cause === null ) return { } ;
187+
188+ const { code, message } = cause as { code ?: unknown ; message ?: unknown } ;
189+ return {
190+ code : typeof code === "string" ? code : undefined ,
191+ message : typeof message === "string" ? message : undefined ,
192+ } ;
193+ }
194+
195+ function describeConnectionError ( error : unknown , url : string ) : string {
196+ const { code, message } = getErrorCause ( error ) ;
197+ const detail = message || ( error instanceof Error ? error . message : String ( error ) ) ;
198+ const hint = ( code && CONNECTION_HINTS [ code ] ) || DEFAULT_HINT ;
199+
200+ return `Could not reach ${ url } : ${ detail } ${ code ? ` (${ code } )` : "" } \n${ hint } ` ;
201+ }
202+
203+ async function postForm ( url : string , params : URLSearchParams ) : Promise < Response > {
204+ try {
205+ return await fetch ( url , {
206+ method : "POST" ,
207+ headers : { "Content-Type" : "application/x-www-form-urlencoded" } ,
208+ body : params . toString ( ) ,
209+ } ) ;
210+ } catch ( error ) {
211+ throw new Error ( describeConnectionError ( error , url ) ) ;
212+ }
213+ }
214+
215+ async function oauthRequest < T > ( url : string , params : URLSearchParams , fallback : string ) : Promise < T > {
216+ const response = await postForm ( url , params ) ;
217+ if ( ! response . ok ) {
218+ throw new Error ( await describeErrorResponse ( response , fallback ) ) ;
219+ }
220+ return ( await response . json ( ) ) as T ;
221+ }
222+
150223/** RFC 8628 §3.2 default poll interval when the server omits `interval`. */
151224export const DEFAULT_DEVICE_POLL_INTERVAL_SECONDS = 5 ;
152225
@@ -165,18 +238,11 @@ export async function startDeviceAuthorization(
165238 // ignore
166239 }
167240
168- const response = await fetch ( `${ baseUrl } /api/oauth/device/code` , {
169- method : "POST" ,
170- headers : { "Content-Type" : "application/x-www-form-urlencoded" } ,
171- body : params . toString ( ) ,
172- } ) ;
173-
174- if ( ! response . ok ) {
175- const err = ( await response . json ( ) . catch ( ( ) => ( { } ) ) ) as TokenErrorResponse ;
176- throw new Error ( err . error_description || err . error || "Failed to start device authorization" ) ;
177- }
178-
179- return ( await response . json ( ) ) as DeviceAuthorizationResponse ;
241+ return oauthRequest < DeviceAuthorizationResponse > (
242+ `${ baseUrl } /api/oauth/device/code` ,
243+ params ,
244+ "Failed to start device authorization"
245+ ) ;
180246}
181247
182248export interface PollDeviceTokenResult {
@@ -192,15 +258,14 @@ export async function pollDeviceToken(
192258) : Promise < PollDeviceTokenResult > {
193259 let response : Response ;
194260 try {
195- response = await fetch ( `${ baseUrl } /api/oauth/device/token` , {
196- method : "POST" ,
197- headers : { "Content-Type" : "application/x-www-form-urlencoded" } ,
198- body : new URLSearchParams ( {
261+ response = await postForm (
262+ `${ baseUrl } /api/oauth/device/token` ,
263+ new URLSearchParams ( {
199264 grant_type : DEVICE_CODE_GRANT ,
200265 device_code : deviceCode ,
201266 client_id : clientId ,
202- } ) . toString ( ) ,
203- } ) ;
267+ } )
268+ ) ;
204269 } catch ( error ) {
205270 // Network blip — keep polling.
206271 return {
0 commit comments