22//!
33//! Distinct from the user-scoped session in [`crate::jwt`]:
44//!
5- //! * Minted by `/v1/auth/sandbox` (or `/v1/auth/sandbox/<id>`), not
6- //! `/o/token/`.
5+ //! * Minted by `POST /v1/auth/sandbox` (with no body, or
6+ //! `grant_type=existing_sandbox` + `sandbox_id`), not ` /o/token/`.
77//! * Bound to a single sandbox + workspace; the JWT carries only
88//! workspace-read + sandbox-read/write scope.
9- //! * Refreshed via `/v1/auth/sandbox/refresh`, which rotates the
10- //! refresh token (single-use). The user's own credentials are never
11- //! involved — possession of the sandbox refresh token is enough.
9+ //! * Refreshed via `POST /v1/auth/sandbox` with
10+ //! `grant_type=refresh_token` — same endpoint as the new-mint path,
11+ //! dispatched by body field (mirrors `POST /o/token/`). The server
12+ //! does **not** rotate the refresh token. The user's own credentials
13+ //! are never involved — possession of the sandbox refresh token is
14+ //! enough.
1215//!
1316//! Stored at `~/.hotdata/sandbox_session.json` (mode 0600).
1417
@@ -91,14 +94,22 @@ fn redact(s: &str) -> String {
9194 util:: mask_credential ( s)
9295}
9396
94- /// Trade a refresh token for a fresh sandbox JWT (and a new refresh
95- /// token). The server rotates: the old refresh token is dead after a
96- /// successful call, so the new value must be persisted before the
97- /// caller can recover from a crash.
97+ /// Trade a refresh token for a fresh sandbox JWT. The server does
98+ /// **not** rotate the refresh token (matches DOT's
99+ /// ``ROTATE_REFRESH_TOKEN=False``), so the same value is returned on
100+ /// every call. Same endpoint as the new-mint path —
101+ /// ``POST /v1/auth/sandbox`` with ``grant_type=refresh_token`` in the
102+ /// body, mirroring ``POST /o/token/``.
98103pub fn refresh ( api_url : & str , refresh_token : & str ) -> Result < SandboxSession , String > {
99- let url = format ! ( "{}/auth/sandbox/refresh" , api_url. trim_end_matches( '/' ) ) ;
100- let body = serde_json:: json!( { "refresh_token" : refresh_token} ) ;
101- let body_log = serde_json:: json!( { "refresh_token" : redact( refresh_token) } ) ;
104+ let url = format ! ( "{}/auth/sandbox" , api_url. trim_end_matches( '/' ) ) ;
105+ let body = serde_json:: json!( {
106+ "grant_type" : "refresh_token" ,
107+ "refresh_token" : refresh_token,
108+ } ) ;
109+ let body_log = serde_json:: json!( {
110+ "grant_type" : "refresh_token" ,
111+ "refresh_token" : redact( refresh_token) ,
112+ } ) ;
102113
103114 let client = reqwest:: blocking:: Client :: new ( ) ;
104115 let req = client. post ( & url) . json ( & body) ;
@@ -303,29 +314,36 @@ mod tests {
303314 }
304315
305316 #[ test]
306- fn refresh_success_rotates_tokens ( ) {
317+ fn refresh_posts_grant_type_to_sandbox_endpoint ( ) {
307318 let mut server = mockito:: Server :: new ( ) ;
308319 let m = server
309- . mock ( "POST" , "/auth/sandbox/refresh" )
320+ . mock ( "POST" , "/auth/sandbox" )
321+ . match_body ( mockito:: Matcher :: AllOf ( vec ! [
322+ mockito:: Matcher :: JsonString (
323+ r#"{"grant_type":"refresh_token","refresh_token":"stable-refresh"}"#
324+ . to_string( ) ,
325+ ) ,
326+ ] ) )
310327 . with_status ( 200 )
311328 . with_header ( "content-type" , "application/json" )
312329 . with_body (
313- r#"{"ok":true,"token":"new-jwt","refresh_token":"new-refresh","sandbox_id":"s_abc12345","expires_in":259200,"refresh_expires_in":2592000}"# ,
330+ // Server does not rotate — same refresh_token comes back.
331+ r#"{"ok":true,"token":"new-jwt","refresh_token":"stable-refresh","sandbox_id":"s_abc12345","expires_in":300,"refresh_expires_in":259200}"# ,
314332 )
315333 . create ( ) ;
316334
317- let s = refresh ( & server. url ( ) , "old -refresh" ) . unwrap ( ) ;
335+ let s = refresh ( & server. url ( ) , "stable -refresh" ) . unwrap ( ) ;
318336 m. assert ( ) ;
319337 assert_eq ! ( s. access_token, "new-jwt" ) ;
320- assert_eq ! ( s. refresh_token, "new -refresh" ) ;
338+ assert_eq ! ( s. refresh_token, "stable -refresh" ) ;
321339 assert_eq ! ( s. sandbox_id, "s_abc12345" ) ;
322340 }
323341
324342 #[ test]
325343 fn refresh_http_error ( ) {
326344 let mut server = mockito:: Server :: new ( ) ;
327345 let m = server
328- . mock ( "POST" , "/auth/sandbox/refresh " )
346+ . mock ( "POST" , "/auth/sandbox" )
329347 . with_status ( 401 )
330348 . create ( ) ;
331349 let err = refresh ( & server. url ( ) , "x" ) . unwrap_err ( ) ;
@@ -343,19 +361,20 @@ mod tests {
343361
344362 let mut server = mockito:: Server :: new ( ) ;
345363 let m = server
346- . mock ( "POST" , "/auth/sandbox/refresh " )
364+ . mock ( "POST" , "/auth/sandbox" )
347365 . with_status ( 200 )
348366 . with_header ( "content-type" , "application/json" )
349367 . with_body (
350- r#"{"ok":true,"token":"refreshed","refresh_token":"rotated ","sandbox_id":"s_abc12345","expires_in":259200 ,"refresh_expires_in":2592000 }"# ,
368+ r#"{"ok":true,"token":"refreshed","refresh_token":"cached-refresh ","sandbox_id":"s_abc12345","expires_in":300 ,"refresh_expires_in":259200 }"# ,
351369 )
352370 . create ( ) ;
353371 let tok = ensure_access_token ( & server. url ( ) ) ;
354372 m. assert ( ) ;
355373 assert_eq ! ( tok. as_deref( ) , Some ( "refreshed" ) ) ;
356374 let after = load ( ) . unwrap ( ) ;
357375 assert_eq ! ( after. access_token, "refreshed" ) ;
358- assert_eq ! ( after. refresh_token, "rotated" ) ;
376+ // No rotation — same refresh_token as before.
377+ assert_eq ! ( after. refresh_token, "cached-refresh" ) ;
359378 assert_eq ! ( after. workspace_id, "work_xyz" ) ;
360379 }
361380}
0 commit comments