Repository: jupyterhub/jupyterhub

Issue:
"Invalid client_id" error message when real problem is that oauth_redirect_uri is missing
<!-- Thank you for contributing. These HTML comments will not render in the issue, but you can delete them once you've read them if you prefer! -->

### Bug description

When configuring JupyterHub to run an external service, I forgot to set `oauth_redirect_uri` in the JupyterHub service config. However, this resulted in an error message saying 'invalid client_id' for the end user, rather than 'invalid oauth_redirect_uri'. This was confusing and led us down a couple blind alleys before debugging.

#### Expected behaviour

Show an error message saying 'invalid oauth_redirect_uri'

#### Actual behaviour

A client error message saying 'invalid client_id'. No details in the JupyterHub logs.

### How to reproduce

1. Check out https://github.com/jupyterhub/jupyterhub/pull/4550 (or `main` if that PR is merged)
2. Go to `examples/external-oauth`
3. Follow instructions in the README to run the service. Note that this *works*, and you can access the external service.
4. In `examples/external-oauth/jupyterhub_config.py`, uncomment `oauth_redirect_uri` in the services config. Restart JupyterHub to load new config. In your browser, clear all cookies for `127.0.0.1:5555` by going to the 'Storage' tab under developer tools - the example oauth process doesn't have logout functionality.
5. Try accessing the the external service now.

You should get an error saying:

<img width="556" alt="image" src="https://github.com/jupyterhub/jupyterhub/assets/30430/188c0f77-0895-4b7c-a02a-8f93386fd70d">



Discussion:

Resolving patch:
diff --git a/jupyterhub/app.py b/jupyterhub/app.py
--- a/jupyterhub/app.py
+++ b/jupyterhub/app.py
@@ -2501,6 +2501,11 @@ def service_from_orm(
         if orm_service.oauth_client is not None:
             service.oauth_client_id = orm_service.oauth_client.identifier
             service.oauth_redirect_uri = orm_service.oauth_client.redirect_uri
+            oauth_msg = f"with ouath_client_id={orm_service.oauth_client.identifier}"
+        else:
+            oauth_msg = "without oauth"
+
+        self.log.info(f"Loaded service {service.name} from database {oauth_msg}.")
 
         self._service_map[name] = service
 
@@ -2626,6 +2631,15 @@ def service_from_spec(
             service.orm.server = None
 
         if service.oauth_available:
+            self.log.info(
+                f"Creating service {service.name} with oauth_client_id={service.oauth_client_id}"
+            )
+            if not service.oauth_redirect_uri:
+                # redirect uri has a default value if a URL is configured,
+                # but must be specified explicitly for external services
+                raise ValueError(
+                    f"Service {service.name} has oauth configured, but is missing required oauth_redirect_uri."
+                )
             allowed_scopes = set()
             if service.oauth_client_allowed_scopes:
                 allowed_scopes.update(service.oauth_client_allowed_scopes)
@@ -2655,7 +2669,11 @@ def service_from_spec(
             allowed_scopes.update(scopes.access_scopes(oauth_client))
             oauth_client.allowed_scopes = sorted(allowed_scopes)
         else:
+            self.log.info(f"Creating service {service.name} without oauth.")
             if service.oauth_client:
+                self.log.warning(
+                    f"Deleting unused oauth client for service {service.name} with client_id={service.oauth_client.identifier}"
+                )
                 self.db.delete(service.oauth_client)
 
         self._service_map[name] = service
diff --git a/jupyterhub/services/service.py b/jupyterhub/services/service.py
--- a/jupyterhub/services/service.py
+++ b/jupyterhub/services/service.py
@@ -52,8 +52,10 @@
     HasTraits,
     Instance,
     List,
+    Set,
     Unicode,
     default,
+    observe,
     validate,
 )
 from traitlets.config import LoggingConfigurable
@@ -306,6 +308,7 @@ def kind(self):
     cookie_options = Dict()
 
     oauth_provider = Any()
+    _oauth_specified = Set(help="List of oauth config fields specified via config.")
 
     oauth_client_id = Unicode(
         help="""OAuth client ID for this service.
@@ -342,13 +345,23 @@ def _default_redirect_uri(self):
             return ''
         return self.host + url_path_join(self.prefix, 'oauth_callback')
 
+    @observe("oauth_client_id", "oauth_redirect_uri", "oauth_no_confirm")
+    def _oauth_config_set(self, change):
+        # record that some oauth config is specified
+        if change.new in {"", None}:
+            # this shouldn't happen, but empty values
+            # may sometimes be set to 'un-set' config
+            self._oauth_specified.pop(change.name, None)
+        else:
+            self._oauth_specified.add(change.name)
+
     @property
     def oauth_available(self):
         """Is OAuth available for this client?
 
-        Returns True if a server is defined or oauth_redirect_uri is specified manually
+        Returns True if a server is defined or any oauth config is provided explicitly
         """
-        return bool(self.server is not None or self.oauth_redirect_uri)
+        return bool(self.server is not None or self._oauth_specified)
 
     @property
     def oauth_client(self):


Test patch: