@@ -42,10 +42,13 @@ struct OidcDiscovery {
4242///
4343/// Validates that the discovery document's `issuer` field matches the
4444/// configured issuer URL to prevent SSRF or misdirection.
45- async fn discover ( issuer : & str ) -> Result < OidcDiscovery > {
45+ async fn discover ( issuer : & str , insecure : bool ) -> Result < OidcDiscovery > {
4646 let normalized_issuer = issuer. trim_end_matches ( '/' ) ;
4747 let url = format ! ( "{normalized_issuer}/.well-known/openid-configuration" ) ;
48- let resp: OidcDiscovery = reqwest:: get ( & url)
48+ let client = http_client ( insecure) ;
49+ let resp: OidcDiscovery = client
50+ . get ( & url)
51+ . send ( )
4952 . await
5053 . into_diagnostic ( ) ?
5154 . json ( )
@@ -63,11 +66,12 @@ async fn discover(issuer: &str) -> Result<OidcDiscovery> {
6366 Ok ( resp)
6467}
6568
66- fn http_client ( ) -> reqwest:: Client {
67- reqwest:: ClientBuilder :: new ( )
68- . redirect ( reqwest:: redirect:: Policy :: none ( ) )
69- . build ( )
70- . expect ( "failed to build HTTP client" )
69+ fn http_client ( insecure : bool ) -> reqwest:: Client {
70+ let mut builder = reqwest:: ClientBuilder :: new ( ) . redirect ( reqwest:: redirect:: Policy :: none ( ) ) ;
71+ if insecure {
72+ builder = builder. danger_accept_invalid_certs ( true ) ;
73+ }
74+ builder. build ( ) . expect ( "failed to build HTTP client" )
7175}
7276
7377fn build_scopes ( scopes : Option < & str > ) -> Vec < Scope > {
@@ -100,8 +104,9 @@ pub async fn oidc_browser_auth_flow(
100104 client_id : & str ,
101105 audience : Option < & str > ,
102106 scopes : Option < & str > ,
107+ insecure : bool ,
103108) -> Result < OidcTokenBundle > {
104- let discovery = discover ( issuer) . await ?;
109+ let discovery = discover ( issuer, insecure ) . await ?;
105110
106111 let listener = TcpListener :: bind ( "127.0.0.1:0" ) . await . into_diagnostic ( ) ?;
107112 let port = listener. local_addr ( ) . into_diagnostic ( ) ?. port ( ) ;
@@ -161,7 +166,7 @@ pub async fn oidc_browser_auth_flow(
161166
162167 server_handle. abort ( ) ;
163168
164- let http = http_client ( ) ;
169+ let http = http_client ( insecure ) ;
165170 let token_response = client
166171 . exchange_code ( AuthorizationCode :: new ( code) )
167172 . set_pkce_verifier ( pkce_verifier)
@@ -184,14 +189,15 @@ pub async fn oidc_client_credentials_flow(
184189 client_id : & str ,
185190 audience : Option < & str > ,
186191 scopes : Option < & str > ,
192+ insecure : bool ,
187193) -> Result < OidcTokenBundle > {
188194 let client_secret = std:: env:: var ( "OPENSHELL_OIDC_CLIENT_SECRET" ) . map_err ( |_| {
189195 miette:: miette!(
190196 "OPENSHELL_OIDC_CLIENT_SECRET environment variable is required for client credentials flow"
191197 )
192198 } ) ?;
193199
194- let discovery = discover ( issuer) . await ?;
200+ let discovery = discover ( issuer, insecure ) . await ?;
195201
196202 let client = BasicClient :: new ( ClientId :: new ( client_id. to_string ( ) ) )
197203 . set_client_secret ( ClientSecret :: new ( client_secret) )
@@ -206,7 +212,7 @@ pub async fn oidc_client_credentials_flow(
206212 request = request. add_extra_param ( "audience" , aud) ;
207213 }
208214
209- let http = http_client ( ) ;
215+ let http = http_client ( insecure ) ;
210216 let token_response = request
211217 . request_async ( & http)
212218 . await
@@ -223,19 +229,22 @@ pub async fn oidc_client_credentials_flow(
223229///
224230/// Preserves the existing refresh token if the server does not return a new
225231/// one (per OAuth 2.0 spec, the refresh response may omit `refresh_token`).
226- pub async fn oidc_refresh_token ( bundle : & OidcTokenBundle ) -> Result < OidcTokenBundle > {
232+ pub async fn oidc_refresh_token (
233+ bundle : & OidcTokenBundle ,
234+ insecure : bool ,
235+ ) -> Result < OidcTokenBundle > {
227236 let refresh_token = bundle. refresh_token . as_deref ( ) . ok_or_else ( || {
228237 miette:: miette!(
229238 "no refresh token available — re-authenticate with: openshell gateway login"
230239 )
231240 } ) ?;
232241
233- let discovery = discover ( & bundle. issuer ) . await ?;
242+ let discovery = discover ( & bundle. issuer , insecure ) . await ?;
234243
235244 let client = BasicClient :: new ( ClientId :: new ( bundle. client_id . clone ( ) ) )
236245 . set_token_uri ( TokenUrl :: new ( discovery. token_endpoint ) . into_diagnostic ( ) ?) ;
237246
238- let http = http_client ( ) ;
247+ let http = http_client ( insecure ) ;
239248 let token_response = client
240249 . exchange_refresh_token ( & RefreshToken :: new ( refresh_token. to_string ( ) ) )
241250 . request_async ( & http)
@@ -253,7 +262,7 @@ pub async fn oidc_refresh_token(bundle: &OidcTokenBundle) -> Result<OidcTokenBun
253262/// Ensure we have a valid OIDC token for the given gateway, refreshing if needed.
254263///
255264/// Returns the access token string.
256- pub async fn ensure_valid_oidc_token ( gateway_name : & str ) -> Result < String > {
265+ pub async fn ensure_valid_oidc_token ( gateway_name : & str , insecure : bool ) -> Result < String > {
257266 let bundle =
258267 openshell_bootstrap:: oidc_token:: load_oidc_token ( gateway_name) . ok_or_else ( || {
259268 miette:: miette!(
@@ -270,7 +279,7 @@ pub async fn ensure_valid_oidc_token(gateway_name: &str) -> Result<String> {
270279 gateway = gateway_name,
271280 "OIDC token expired, attempting refresh"
272281 ) ;
273- let refreshed = oidc_refresh_token ( & bundle) . await ?;
282+ let refreshed = oidc_refresh_token ( & bundle, insecure ) . await ?;
274283 openshell_bootstrap:: oidc_token:: store_oidc_token ( gateway_name, & refreshed) ?;
275284 Ok ( refreshed. access_token )
276285}
@@ -436,3 +445,90 @@ fn html_response(status: StatusCode, message: &str) -> Response<Full<Bytes>> {
436445 . body ( Full :: new ( Bytes :: from ( body) ) )
437446 . expect ( "response" )
438447}
448+
449+ #[ cfg( test) ]
450+ mod tests {
451+ use super :: * ;
452+
453+ #[ test]
454+ fn http_client_secure_rejects_self_signed ( ) {
455+ let client = http_client ( false ) ;
456+ let rt = tokio:: runtime:: Runtime :: new ( ) . unwrap ( ) ;
457+ // A real self-signed server isn't available in unit tests, but we can
458+ // verify the client is constructed and makes requests. The secure client
459+ // should exist and function for valid endpoints.
460+ let result = rt. block_on ( async { client. get ( "https://127.0.0.1:1" ) . send ( ) . await } ) ;
461+ assert ! ( result. is_err( ) , "connection to closed port should fail" ) ;
462+ }
463+
464+ #[ test]
465+ fn http_client_insecure_builds_without_panic ( ) {
466+ let client = http_client ( true ) ;
467+ // Verify the client is usable (doesn't panic on construction).
468+ let rt = tokio:: runtime:: Runtime :: new ( ) . unwrap ( ) ;
469+ let result = rt. block_on ( async { client. get ( "https://127.0.0.1:1" ) . send ( ) . await } ) ;
470+ assert ! ( result. is_err( ) , "connection to closed port should fail" ) ;
471+ }
472+
473+ #[ test]
474+ fn discover_validates_issuer_mismatch ( ) {
475+ let rt = tokio:: runtime:: Runtime :: new ( ) . unwrap ( ) ;
476+ // Discovery against a non-existent issuer should fail with a
477+ // connection error, not silently succeed.
478+ let result = rt. block_on ( discover ( "http://127.0.0.1:1/realms/test" , false ) ) ;
479+ assert ! ( result. is_err( ) ) ;
480+ }
481+
482+ #[ test]
483+ fn discover_insecure_passes_flag_through ( ) {
484+ let rt = tokio:: runtime:: Runtime :: new ( ) . unwrap ( ) ;
485+ // Same as above but with insecure=true. Should still fail on
486+ // connection (no server) but must not panic.
487+ let result = rt. block_on ( discover ( "https://127.0.0.1:1/realms/test" , true ) ) ;
488+ assert ! ( result. is_err( ) ) ;
489+ }
490+
491+ #[ test]
492+ fn percent_decode_basic ( ) {
493+ assert_eq ! ( percent_decode( "hello%20world" ) , "hello world" ) ;
494+ assert_eq ! ( percent_decode( "a%2Fb" ) , "a/b" ) ;
495+ assert_eq ! ( percent_decode( "no+encoding+here" ) , "no encoding here" ) ;
496+ }
497+
498+ #[ test]
499+ fn build_scopes_always_includes_openid ( ) {
500+ let scopes = build_scopes ( None ) ;
501+ assert_eq ! ( scopes. len( ) , 1 ) ;
502+
503+ let scopes = build_scopes ( Some ( "profile email" ) ) ;
504+ assert_eq ! ( scopes. len( ) , 3 ) ;
505+ }
506+
507+ #[ test]
508+ fn build_scopes_deduplicates_openid ( ) {
509+ let scopes = build_scopes ( Some ( "openid profile" ) ) ;
510+ assert_eq ! ( scopes. len( ) , 2 ) ;
511+ }
512+
513+ #[ test]
514+ fn build_ci_scopes_empty_on_none ( ) {
515+ let scopes = build_ci_scopes ( None ) ;
516+ assert ! ( scopes. is_empty( ) ) ;
517+ }
518+
519+ #[ test]
520+ fn bundle_from_response_sets_fields ( ) {
521+ use oauth2:: basic:: BasicTokenResponse ;
522+
523+ let token_response: BasicTokenResponse = serde_json:: from_str (
524+ r#"{"access_token":"test-access","token_type":"bearer","expires_in":300,"refresh_token":"test-refresh"}"# ,
525+ )
526+ . unwrap ( ) ;
527+ let bundle = bundle_from_oauth2_response ( & token_response, "https://issuer" , "my-client" ) ;
528+ assert_eq ! ( bundle. access_token, "test-access" ) ;
529+ assert_eq ! ( bundle. refresh_token. as_deref( ) , Some ( "test-refresh" ) ) ;
530+ assert_eq ! ( bundle. issuer, "https://issuer" ) ;
531+ assert_eq ! ( bundle. client_id, "my-client" ) ;
532+ assert ! ( bundle. expires_at. is_some( ) ) ;
533+ }
534+ }
0 commit comments