From: Alexandre JARDON <28548335+webalexeu@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:05:22 +0000 (+0200) Subject: mgr/dashboard: Improve oauth2 sso configuration X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=70328ef12a98846553f1395a92bd975065b2e7d9;p=ceph.git mgr/dashboard: Improve oauth2 sso configuration Add possibility to configure email_domains and scope parameters in oauth2 sso configuration Fixes: https://tracker.ceph.com/issues/75020 mgr/dashboard: Fix oauth2 sso missing jti claim review Fixes: https://tracker.ceph.com/issues/75022 mgr/dashboard: Improve oauth2 sso configuration Add possibility to configure ssl_insecure_skip_verify parameter in oauth2 sso configuration Fixes: https://tracker.ceph.com/issues/75984 Signed-off-by: Alexandre Jardon --- diff --git a/doc/cephadm/services/oauth2-proxy.rst b/doc/cephadm/services/oauth2-proxy.rst index 515ba4410de6..4f0ab2e84172 100644 --- a/doc/cephadm/services/oauth2-proxy.rst +++ b/doc/cephadm/services/oauth2-proxy.rst @@ -118,6 +118,7 @@ A non-exhaustive list of important limitations for the `oauth2-proxy` service fo * High-availability configurations for `oauth2-proxy` itself are not supported. * Proper configuration of the IDP and OAuth2 parameters is crucial to avoid authentication failures. Misconfigurations can lead to access issues. +* IDP must include the jti claim in the issued JWT token because the Ceph Dashboard relies on this value to verify the token's validity. Container images diff --git a/src/pybind/mgr/cephadm/services/oauth2_proxy.py b/src/pybind/mgr/cephadm/services/oauth2_proxy.py index 5a36b5a6adb3..66d53bbe9dd5 100644 --- a/src/pybind/mgr/cephadm/services/oauth2_proxy.py +++ b/src/pybind/mgr/cephadm/services/oauth2_proxy.py @@ -64,10 +64,12 @@ class OAuth2ProxyService(CephadmService): svc_spec = cast(OAuth2ProxySpec, self.mgr.spec_store[daemon_spec.service_name].spec) allowlist_domains = copy(svc_spec.allowlist_domains) or [] allowlist_domains += self.get_service_ips_and_hosts('mgmt-gateway') + email_domains = copy(svc_spec.email_domains) or [] context = { 'spec': svc_spec, 'cookie_secret': svc_spec.cookie_secret, 'allowlist_domains': allowlist_domains, + 'email_domains': email_domains, 'redirect_url': svc_spec.redirect_url or self.get_redirect_url() } diff --git a/src/pybind/mgr/cephadm/templates/services/oauth2-proxy/oauth2-proxy.conf.j2 b/src/pybind/mgr/cephadm/templates/services/oauth2-proxy/oauth2-proxy.conf.j2 index c8d9f920adf5..5244f1e72c5b 100644 --- a/src/pybind/mgr/cephadm/templates/services/oauth2-proxy/oauth2-proxy.conf.j2 +++ b/src/pybind/mgr/cephadm/templates/services/oauth2-proxy/oauth2-proxy.conf.j2 @@ -14,8 +14,13 @@ oidc_issuer_url= "{{ spec.oidc_issuer_url }}" {% if redirect_url %} redirect_url= "{{ redirect_url }}" {% endif %} +{% if spec.scope %} +scope= "{{ spec.scope }}" +{% endif %} +{% if spec.ssl_insecure_skip_verify %} ssl_insecure_skip_verify=true +{% endif %} # following configuration is needed to avoid getting Forbidden # when using chrome like browsers as they handle 3rd party cookies @@ -33,5 +38,5 @@ set_xauthrequest= true # Secret value for encrypting cookies. cookie_secret= "{{ cookie_secret }}" -email_domains= "*" +email_domains= "{{ (email_domains | join(',')) or '*' }}" whitelist_domains= "{{ allowlist_domains | join(',') }}" diff --git a/src/pybind/mgr/cephadm/tests/services/test_mgmt_gateway.py b/src/pybind/mgr/cephadm/tests/services/test_mgmt_gateway.py index 6e79311e88dd..63fdef636d67 100644 --- a/src/pybind/mgr/cephadm/tests/services/test_mgmt_gateway.py +++ b/src/pybind/mgr/cephadm/tests/services/test_mgmt_gateway.py @@ -779,7 +779,6 @@ class TestMgmtGateway: oidc_issuer_url= "http://192.168.10.10:8888/dex" redirect_url= "{redirect_url}" - ssl_insecure_skip_verify=true # following configuration is needed to avoid getting Forbidden # when using chrome like browsers as they handle 3rd party cookies diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.html index 648b0282202d..9be973353be5 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.html @@ -1032,6 +1032,22 @@ } + +
+ + +
+ +
+ + +
+ +
+ + Skip TLS verification + + Skip TLS certificate verification for the OIDC provider. Use only in non-production environments. + + +
} @if (!serviceForm.controls.unmanaged.value && ['mgmt-gateway'].includes(serviceForm.controls.service_type.value)) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts index 6ecb3ba29158..cd17d88fb25c 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts @@ -657,7 +657,10 @@ export class ServiceFormComponent extends CdForm implements OnInit { ], https_address: [null, [CdValidators.oauthAddressTest()]], redirect_url: [null], - allowlist_domains: [null] + scope: [null], + email_domains: [null], + allowlist_domains: [null], + ssl_insecure_skip_verify: [false] }); } @@ -915,7 +918,10 @@ export class ServiceFormComponent extends CdForm implements OnInit { 'client_secret', 'oidc_issuer_url', 'redirect_url', - 'allowlist_domains' + 'scope', + 'email_domains', + 'allowlist_domains', + 'ssl_insecure_skip_verify' ]; oauth2SpecKeys.forEach((key) => { this.serviceForm.get(key).setValue(response[0].spec[key]); @@ -1433,6 +1439,12 @@ export class ServiceFormComponent extends CdForm implements OnInit { serviceSpec['oidc_issuer_url'] = values['oidc_issuer_url']?.trim(); serviceSpec['https_address'] = values['https_address']?.trim(); serviceSpec['redirect_url'] = values['redirect_url']?.trim(); + serviceSpec['scope'] = values['scope']?.join(' '); + if (values['email_domains']) { + serviceSpec['email_domains'] = values['email_domains']?.map((emailDomain: string) => { + return emailDomain.trim(); + }); + } if (values['allowlist_domains']) { serviceSpec['allowlist_domains'] = values['allowlist_domains']?.map( (allowlistDomain: string) => { @@ -1440,6 +1452,7 @@ export class ServiceFormComponent extends CdForm implements OnInit { } ); } + serviceSpec['ssl_insecure_skip_verify'] = values['ssl_insecure_skip_verify']; if (values['ssl']) { this.applySslCertificateConfig(serviceSpec, values); } diff --git a/src/pybind/mgr/dashboard/services/auth/auth.py b/src/pybind/mgr/dashboard/services/auth/auth.py index d49a9528faa3..eaa0440673b5 100644 --- a/src/pybind/mgr/dashboard/services/auth/auth.py +++ b/src/pybind/mgr/dashboard/services/auth/auth.py @@ -227,16 +227,19 @@ class JwtManager(object): def get_user(cls, token): try: dtoken = cls.decode_token(token) - if 'jti' in dtoken and not cls.is_blocklisted(dtoken['jti']): - user = AuthManager.get_user(dtoken['username']) - if 'iat' in dtoken and user.last_update <= dtoken['iat']: - return user - cls.logger.debug( # type: ignore - "user info changed after token was issued, iat=%s last_update=%s", - dtoken['iat'], user.last_update - ) + if 'jti' in dtoken: + if not cls.is_blocklisted(dtoken['jti']): + user = AuthManager.get_user(dtoken['username']) + if 'iat' in dtoken and user.last_update <= dtoken['iat']: + return user + cls.logger.debug( # type: ignore + "user info changed after token was issued, iat=%s last_update=%s", + dtoken['iat'], user.last_update + ) + else: + cls.logger.debug('Token is block-listed') # type: ignore else: - cls.logger.debug('Token is block-listed') # type: ignore + cls.logger.debug('Missing jti claim in token') # type: ignore except ExpiredSignatureError: cls.logger.debug("Token has expired") # type: ignore except InvalidTokenError: diff --git a/src/python-common/ceph/deployment/service_spec.py b/src/python-common/ceph/deployment/service_spec.py index 246a9b898c6b..c0df3ace01ba 100644 --- a/src/python-common/ceph/deployment/service_spec.py +++ b/src/python-common/ceph/deployment/service_spec.py @@ -2563,13 +2563,16 @@ class OAuth2ProxySpec(ServiceSpec): client_secret: Optional[str] = None, oidc_issuer_url: Optional[str] = None, redirect_url: Optional[str] = None, + scope: Optional[str] = None, cookie_secret: Optional[str] = None, ssl_cert: Optional[str] = None, ssl_key: Optional[str] = None, ssl: Optional[bool] = True, certificate_source: Optional[str] = None, custom_sans: Optional[List[str]] = None, + email_domains: Optional[List[str]] = None, allowlist_domains: Optional[List[str]] = None, + ssl_insecure_skip_verify: Optional[bool] = False, unmanaged: bool = False, extra_container_args: Optional[GeneralArgList] = None, extra_entrypoint_args: Optional[GeneralArgList] = None, @@ -2603,12 +2606,19 @@ class OAuth2ProxySpec(ServiceSpec): #: The URL oauth2-proxy will redirect to after a successful login. If not provided # cephadm will calculate automatically the value of this url. self.redirect_url = redirect_url + #: OAuth scope specification. + # Default list of scopes will be used in case no scope is configured. + self.scope = scope #: The secret key used for signing cookies. Its length must be 16, # 24, or 32 bytes to create an AES cipher. self.cookie_secret = cookie_secret or self.generate_random_secret() + #: List of allowed email domains. + self.email_domains = email_domains #: List of allowed domains for safe redirection after login or logout, # preventing unauthorized redirects. self.allowlist_domains = allowlist_domains + #: Skip TLS verification for the OIDC provider. Use only in non-production environments. + self.ssl_insecure_skip_verify = ssl_insecure_skip_verify self.unmanaged = unmanaged def generate_random_secret(self) -> str: @@ -2630,6 +2640,10 @@ class OAuth2ProxySpec(ServiceSpec): self._validate_url(self.oidc_issuer_url, "oidc_issuer_url") if self.redirect_url is not None: self._validate_url(self.redirect_url, "redirect_url") + if self.scope is not None: + self._validate_non_empty_string(self.scope, "scope") + if self.email_domains is not None: + self._validate_domain_name(self.email_domains, "email_domains") if self.https_address is not None: self._validate_https_address(self.https_address) @@ -2647,6 +2661,22 @@ class OAuth2ProxySpec(ServiceSpec): if not all([result.scheme, result.netloc]): raise SpecValidationError(f"Error parsing {field_name} field: Must be a valid URL.") + def _validate_domain_name(self, domains: Optional[List[str]], field_name: str) -> None: + from urllib.parse import urlparse + for domain in (domains or []): + try: + result = urlparse(f"http://{domain}") + except Exception as e: + raise SpecValidationError( + f"Invalid {field_name}: {e}. Must be a valid domain name." + ) + else: + if result.netloc != domain: + raise SpecValidationError( + f"Invalid {field_name}: '{domain}' is not a valid domain name. " + f"Must be a valid domain (e.g., 'domain.test')." + ) + def _validate_https_address(self, https_address: Optional[str]) -> None: from urllib.parse import urlparse result = urlparse(f'http://{https_address}')