88//! present the configured shared secret. The token is accepted via, in order:
99//! 1. `X-Admin-Token` header (any method)
1010//! 2. `Authorization: Bearer <token>` header (any method)
11- //! 3. `?token=<token>` query parameter (GET only, for dashboard links)
11+ //! 3. `Authorization: Basic <base64(user:token)>` (any method; token may be
12+ //! in either the user or password field — needed so plain browsers can
13+ //! authenticate to the dashboard via the native HTTP-auth prompt)
14+ //! 4. `?token=<token>` query parameter (GET only, for dashboard links)
1215//!
13- //! For (3 ), the `token` query parameter is stripped from the request URI after
16+ //! For (4 ), the `token` query parameter is stripped from the request URI after
1417//! successful validation so it doesn't propagate to access logs, downstream
1518//! handlers, or the Referer header.
1619//!
17- //! Rejected requests are forwarded to a sentinel route that returns HTTP 401,
18- //! so all admin routes (prpc-generated and dashboard) are protected by a single
19- //! attachment without modifying the route declarations.
20+ //! Rejected requests are forwarded to a sentinel route that returns HTTP 401
21+ //! with `WWW-Authenticate: Basic realm="dstack-gateway admin"` so browsers
22+ //! show the native login prompt. All admin routes (prpc-generated and
23+ //! dashboard) are protected by this single fairing attachment without
24+ //! modifying the route declarations.
2025//!
2126//! The token is only ever held in memory as its SHA-256 hash; the configured
2227//! plaintext is dropped right after the fairing is constructed.
2328
2429use anyhow:: { bail, Result } ;
30+ use base64:: { engine:: general_purpose:: STANDARD as BASE64 , Engine as _} ;
2531use rocket:: {
2632 fairing:: { Fairing , Info , Kind } ,
27- http:: { uri:: Origin , Method , Status } ,
28- Data , Request , Route ,
33+ http:: { uri:: Origin , Header , Method , Status } ,
34+ response:: Responder ,
35+ Data , Request , Response , Route ,
2936} ;
3037use sha2:: { Digest , Sha256 } ;
3138use subtle:: ConstantTimeEq ;
@@ -37,6 +44,7 @@ const HEADER_NAME: &str = "X-Admin-Token";
3744const QUERY_PARAM : & str = "token" ;
3845const ENV_ADMIN_TOKEN : & str = "DSTACK_GATEWAY_ADMIN_TOKEN" ;
3946const ENV_ADMIN_TOKEN_COMPAT : & str = "ADMIN_API_TOKEN" ;
47+ const BASIC_REALM : & str = "dstack-gateway admin" ;
4048
4149pub struct AdminAuthFairing {
4250 /// SHA-256 of the configured token. `None` = auth disabled (insecure mode).
@@ -83,7 +91,12 @@ impl AdminAuthFairing {
8391 }
8492 if let Some ( auth) = req. headers ( ) . get_one ( "Authorization" ) {
8593 if let Some ( t) = auth. strip_prefix ( "Bearer " ) {
86- return Some ( t. to_string ( ) ) ;
94+ return Some ( t. trim ( ) . to_string ( ) ) ;
95+ }
96+ if let Some ( b64) = auth. strip_prefix ( "Basic " ) {
97+ if let Some ( t) = basic_auth_token ( b64. trim ( ) ) {
98+ return Some ( t) ;
99+ }
87100 }
88101 }
89102 // Query token is intended for browser links to the dashboard, so only
@@ -103,6 +116,37 @@ fn sha256(bytes: &[u8]) -> [u8; 32] {
103116 Sha256 :: digest ( bytes) . into ( )
104117}
105118
119+ /// Decode a `Basic` credential and return whichever of user/password is
120+ /// non-empty (we accept either so the browser prompt's two fields are
121+ /// interchangeable for the operator).
122+ fn basic_auth_token ( b64 : & str ) -> Option < String > {
123+ let decoded = BASE64 . decode ( b64) . ok ( ) ?;
124+ let text = std:: str:: from_utf8 ( & decoded) . ok ( ) ?;
125+ let ( user, pass) = text. split_once ( ':' ) . unwrap_or ( ( text, "" ) ) ;
126+ if !pass. is_empty ( ) {
127+ return Some ( pass. to_string ( ) ) ;
128+ }
129+ if !user. is_empty ( ) {
130+ return Some ( user. to_string ( ) ) ;
131+ }
132+ None
133+ }
134+
135+ /// 401 response that triggers the browser's native HTTP-auth prompt.
136+ struct Unauthorized ;
137+
138+ impl < ' r > Responder < ' r , ' static > for Unauthorized {
139+ fn respond_to ( self , _req : & ' r Request < ' _ > ) -> rocket:: response:: Result < ' static > {
140+ Response :: build ( )
141+ . status ( Status :: Unauthorized )
142+ . header ( Header :: new (
143+ "WWW-Authenticate" ,
144+ format ! ( "Basic realm=\" {BASIC_REALM}\" " ) ,
145+ ) )
146+ . ok ( )
147+ }
148+ }
149+
106150/// Rebuild the request URI without the `token` query parameter, if present.
107151/// Returns `None` when there is nothing to strip.
108152fn strip_token_query ( uri : & Origin < ' _ > ) -> Option < Origin < ' static > > {
@@ -166,38 +210,38 @@ impl Fairing for AdminAuthFairing {
166210// enumerate them because Rocket doesn't support a method-agnostic route.
167211
168212#[ rocket:: get( "/__admin_unauthorized" ) ]
169- fn unauth_get ( ) -> Status {
170- Status :: Unauthorized
213+ fn unauth_get ( ) -> Unauthorized {
214+ Unauthorized
171215}
172216
173217#[ rocket:: post( "/__admin_unauthorized" , data = "<_data>" ) ]
174- fn unauth_post ( _data : Data < ' _ > ) -> Status {
175- Status :: Unauthorized
218+ fn unauth_post ( _data : Data < ' _ > ) -> Unauthorized {
219+ Unauthorized
176220}
177221
178222#[ rocket:: put( "/__admin_unauthorized" , data = "<_data>" ) ]
179- fn unauth_put ( _data : Data < ' _ > ) -> Status {
180- Status :: Unauthorized
223+ fn unauth_put ( _data : Data < ' _ > ) -> Unauthorized {
224+ Unauthorized
181225}
182226
183227#[ rocket:: patch( "/__admin_unauthorized" , data = "<_data>" ) ]
184- fn unauth_patch ( _data : Data < ' _ > ) -> Status {
185- Status :: Unauthorized
228+ fn unauth_patch ( _data : Data < ' _ > ) -> Unauthorized {
229+ Unauthorized
186230}
187231
188232#[ rocket:: delete( "/__admin_unauthorized" ) ]
189- fn unauth_delete ( ) -> Status {
190- Status :: Unauthorized
233+ fn unauth_delete ( ) -> Unauthorized {
234+ Unauthorized
191235}
192236
193237#[ rocket:: options( "/__admin_unauthorized" ) ]
194- fn unauth_options ( ) -> Status {
195- Status :: Unauthorized
238+ fn unauth_options ( ) -> Unauthorized {
239+ Unauthorized
196240}
197241
198242#[ rocket:: head( "/__admin_unauthorized" ) ]
199- fn unauth_head ( ) -> Status {
200- Status :: Unauthorized
243+ fn unauth_head ( ) -> Unauthorized {
244+ Unauthorized
201245}
202246
203247pub fn routes ( ) -> Vec < Route > {
@@ -444,4 +488,71 @@ mod tests {
444488 ) ;
445489 }
446490 }
491+
492+ fn basic_header ( user : & str , pass : & str ) -> Header < ' static > {
493+ let creds = format ! ( "{user}:{pass}" ) ;
494+ Header :: new ( "Authorization" , format ! ( "Basic {}" , BASE64 . encode( creds) ) )
495+ }
496+
497+ #[ rocket:: async_test]
498+ async fn basic_auth_password_field_accepted ( ) {
499+ let client = make_client ( "s3cret" ) . await ;
500+ let resp = client
501+ . get ( "/protected" )
502+ . header ( basic_header ( "admin" , "s3cret" ) )
503+ . dispatch ( )
504+ . await ;
505+ assert_eq ! ( resp. status( ) , Status :: Ok ) ;
506+ }
507+
508+ #[ rocket:: async_test]
509+ async fn basic_auth_user_field_accepted_when_password_empty ( ) {
510+ let client = make_client ( "s3cret" ) . await ;
511+ // Some browser users paste the token into the username field by mistake.
512+ let resp = client
513+ . get ( "/protected" )
514+ . header ( basic_header ( "s3cret" , "" ) )
515+ . dispatch ( )
516+ . await ;
517+ assert_eq ! ( resp. status( ) , Status :: Ok ) ;
518+ }
519+
520+ #[ rocket:: async_test]
521+ async fn basic_auth_wrong_password_rejected ( ) {
522+ let client = make_client ( "s3cret" ) . await ;
523+ let resp = client
524+ . get ( "/protected" )
525+ . header ( basic_header ( "admin" , "wrong" ) )
526+ . dispatch ( )
527+ . await ;
528+ assert_eq ! ( resp. status( ) , Status :: Unauthorized ) ;
529+ }
530+
531+ #[ rocket:: async_test]
532+ async fn basic_auth_malformed_rejected ( ) {
533+ let client = make_client ( "s3cret" ) . await ;
534+ // Not valid base64 at all.
535+ let resp = client
536+ . get ( "/protected" )
537+ . header ( Header :: new ( "Authorization" , "Basic !!not-base64!!" ) )
538+ . dispatch ( )
539+ . await ;
540+ assert_eq ! ( resp. status( ) , Status :: Unauthorized ) ;
541+ }
542+
543+ #[ rocket:: async_test]
544+ async fn unauthorized_response_includes_www_authenticate ( ) {
545+ let client = make_client ( "s3cret" ) . await ;
546+ let resp = client. get ( "/protected" ) . dispatch ( ) . await ;
547+ assert_eq ! ( resp. status( ) , Status :: Unauthorized ) ;
548+ let www = resp
549+ . headers ( )
550+ . get_one ( "WWW-Authenticate" )
551+ . expect ( "missing WWW-Authenticate header" ) ;
552+ assert ! (
553+ www. starts_with( "Basic realm=" ) ,
554+ "expected Basic challenge, got {www:?}"
555+ ) ;
556+ assert ! ( www. contains( "dstack-gateway admin" ) ) ;
557+ }
447558}
0 commit comments