diff --git a/doc/source/authentication.rst b/doc/source/authentication.rst index 892eeee6d9..4c41c1acb5 100644 --- a/doc/source/authentication.rst +++ b/doc/source/authentication.rst @@ -151,4 +151,5 @@ authentication in Zuul and Zuul's Web UI. howtos/openid-with-google howtos/openid-with-keycloak + howtos/openid-with-microsoft tutorials/keycloak diff --git a/doc/source/configuration.rst b/doc/source/configuration.rst index bab028bf54..48865a60ae 100644 --- a/doc/source/configuration.rst +++ b/doc/source/configuration.rst @@ -992,6 +992,28 @@ authentication on Zuul's web user interface. The well-known configuration of the Identity Provider should provide this URL under the key "jwks_uri", therefore this attribute is usually not necessary. +Some providers may not conform to the JWT specification and further +configuration may be necessary. In these cases, the following +additional values may be used: + +.. attr:: authority + :default: issuer_id + + If the authority in the token response is not the same as the + issuer_id in the request, it may be explicitly set here. + +.. attr:: audience + :default: client_id + + If the audience in the token response is not the same as the + issuer_id in the request, it may be explicitly set here. + +.. attr:: load_user_info + :default: true + + If the web UI should skip accessing the "UserInfo" endpoint and + instead rely only on the information returned in the token, set + this to ``false``. Client ------ diff --git a/doc/source/howtos/openid-with-microsoft.rst b/doc/source/howtos/openid-with-microsoft.rst new file mode 100644 index 0000000000..bf2992d466 --- /dev/null +++ b/doc/source/howtos/openid-with-microsoft.rst @@ -0,0 +1,84 @@ +Configuring Microsoft Authentication +==================================== + +This document explains how to configure Zuul in order to enable +authentication with Microsoft Login. + +Prerequisites +------------- + +* The Zuul instance must be able to query Microsoft's OAUTH API servers. This + simply generally means that the Zuul instance must be able to send and + receive HTTPS data to and from the Internet. +* You must have an Active Directory instance in Azure and the ability + to create an App Registration. + +By convention, we will assume Zuul's Web UI's base URL is +``https://zuul.example.com/``. + +Creating the App Registration +----------------------------- + +Navigate to the Active Directory instance in Azure and select `App +registrations` under ``Manage``. Select ``New registration``. This +will open a dialog to register an application. + +Enter a name of your choosing (e.g., ``Zuul``), and select which +account types should have access. Under ``Redirect URI`` select +``Single-page application(SPA)`` and enter +``https://zuul.example.com/auth_callback`` as the redirect URI. Press +the ``Register`` button. + +You should now be at the overview of the Zuul App registration. This +page displays several values which will be used later. Record the +``Application (client) ID`` and ``Directory (tenant) ID``. When we need +to construct values including these later, we will refer to them with +all caps (e.g., ``CLIENT_ID`` and ``TENANT_ID`` respectively). + +Select ``Authentication`` under ``Manage``. You should see a +``Single-page application`` section with the redirect URI previously +configured during registration; if not, correct that now. + +Under ``Implicit grant and hybrid flows`` select both ``Access +tokens`` and ``ID tokens``, then Save. + +Back at the Zuul App Registration menu, select ``Expose an API``, then +press ``Set`` and then press ``Save`` to accept the default +Application ID URI (it should look like ``api://CLIENT_ID``). + +Press ``Add a scope`` and enter ``zuul`` as the scope name. Enter +``Access zuul`` for both the ``Admin consent display name`` and +``Admin consent description``. Leave ``Who can consent`` set to +``Admins only``, then press ``Add scope``. + +Optional: Include Groups Claim +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In order to include group information in the token sent to Zuul, +select ``Token configuration`` under ``Manage`` and then ``Add groups +claim``. + + +Setting up Zuul +--------------- + +Edit the ``/etc/zuul/zuul.conf`` to add the microsoft authenticator: + +.. code-block:: ini + + [auth microsoft] + default=true + driver=OpenIDConnect + realm=zuul.example.com + authority=https://login.microsoftonline.com/TENANT_ID/v2.0 + issuer_id=https://sts.windows.net/TENANT_ID/ + client_id=CLIENT_ID + scope=openid profile api://CLIENT_ID/zuul + audience=api://CLIENT_ID + load_user_info=false + +Restart Zuul services (scheduler, web). + +Head to your tenant's status page. If all went well, you should see a +`Sign in` button in the upper right corner of the +page. Congratulations! diff --git a/releasenotes/notes/ms-login-5be892f3c151bfe7.yaml b/releasenotes/notes/ms-login-5be892f3c151bfe7.yaml new file mode 100644 index 0000000000..363264d6f2 --- /dev/null +++ b/releasenotes/notes/ms-login-5be892f3c151bfe7.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + The ability to configure Microsoft Login as an OpenID Connect + authentication provider has been added. diff --git a/tests/unit/test_web.py b/tests/unit/test_web.py index a21cc773d9..fa9e8c1580 100644 --- a/tests/unit/test_web.py +++ b/tests/unit/test_web.py @@ -1312,6 +1312,7 @@ class TestWebCapabilitiesInfo(TestInfo): 'type': 'JWT', 'scope': 'openid profile', 'driver': 'OpenIDConnect', + 'load_user_info': True, }, 'myOIDC2': { 'authority': 'http://oidc2', @@ -1319,6 +1320,7 @@ class TestWebCapabilitiesInfo(TestInfo): 'type': 'JWT', 'scope': 'openid profile email special-scope', 'driver': 'OpenIDConnect', + 'load_user_info': True, }, 'zuul.example.com': { 'authority': 'zuul_operator', diff --git a/web/src/actions/auth.js b/web/src/actions/auth.js index 1730e04f40..842e57ead8 100644 --- a/web/src/actions/auth.js +++ b/web/src/actions/auth.js @@ -36,6 +36,7 @@ function createAuthParamsFromJson(json) { authority: '', client_id: '', scope: '', + loadUserInfo: true, } if (!auth_info) { console.log('No auth config') @@ -47,6 +48,7 @@ function createAuthParamsFromJson(json) { auth_params.client_id = client_config.client_id auth_params.scope = client_config.scope auth_params.authority = client_config.authority + auth_params.loadUserInfo = client_config.load_user_info return auth_params } else { console.log('No OpenIDConnect provider found') diff --git a/web/src/reducers/auth.js b/web/src/reducers/auth.js index bdbe179304..8f990ea22e 100644 --- a/web/src/reducers/auth.js +++ b/web/src/reducers/auth.js @@ -26,6 +26,7 @@ let auth_params = { authority: '', client_id: '', scope: '', + loadUserInfo: true, } if (stored_params !== null) { auth_params = JSON.parse(stored_params) diff --git a/zuul/driver/auth/jwt.py b/zuul/driver/auth/jwt.py index 7fe23f2ffe..de2fca501e 100644 --- a/zuul/driver/auth/jwt.py +++ b/zuul/driver/auth/jwt.py @@ -22,10 +22,34 @@ from urllib.parse import urljoin from zuul import exceptions from zuul.driver import AuthenticatorInterface +from zuul.lib.config import any_to_bool logger = logging.getLogger("zuul.auth.jwt") +# A few notes on differences between the OpenID Connect specification +# and OIDC as implemented by Microsoft, which necessitates several +# extra configuration options: +# +# 1) The issuer (iss) returned in the JWT is not the authority, but +# rather a URL referring to the AD instance, therefore authority must +# be specified separately. +# 2) The audience (aud) returned in the JWT is not the client_id, but +# rather a URL constructed from the client_id, therefore must also be +# specified separately. +# 3) By default, the JWT is simply a forwarded copy of a token from +# the Microsoft Graph service. It is signed by the graph service and +# not the authority as requested, it therefore fails signature +# validation. In order to cause the authority to generate its own +# token signed by the expected keys, we must create a new scope +# (api://.../zuul) and request that scope with the token. +# 4) The userinfo service referred to by the JWT is the Microsoft +# Graph service, which means that once our token is signed by the +# Microsoft login keys (see item #3) the graph service is then unable +# to validate the token. Therefore we must configure the javascript +# oidc library not to request userinfo and rely only on what is +# supplied in the token. + class JWTAuthenticator(AuthenticatorInterface): """The base class for JWT-based authentication.""" @@ -34,19 +58,17 @@ class JWTAuthenticator(AuthenticatorInterface): # Common configuration for all authenticators self.uid_claim = conf.get('uid_claim', 'sub') self.issuer_id = conf.get('issuer_id') - self.audience = conf.get('client_id') + self.authority = conf.get('authority', self.issuer_id) + self.client_id = conf.get('client_id') + self.audience = conf.get('audience', self.client_id) self.realm = conf.get('realm') - self.allow_authz_override = conf.get('allow_authz_override', False) + self.allow_authz_override = any_to_bool( + conf.get('allow_authz_override', False)) try: self.skew = int(conf.get('skew', 0)) except Exception: raise ValueError( 'skew must be an integer, got %s' % conf.get('skew')) - if isinstance(self.allow_authz_override, str): - if self.allow_authz_override.lower() == 'true': - self.allow_authz_override = True - else: - self.allow_authz_override = False try: self.max_validity_time = float(conf.get('max_validity_time', math.inf)) @@ -56,8 +78,8 @@ class JWTAuthenticator(AuthenticatorInterface): def get_capabilities(self): return { self.realm: { - 'authority': self.issuer_id, - 'client_id': self.audience, + 'authority': self.authority, + 'client_id': self.client_id, 'type': 'JWT', 'driver': getattr(self, 'name', 'N/A'), } @@ -190,6 +212,8 @@ class OpenIDConnectAuthenticator(JWTAuthenticator): super(OpenIDConnectAuthenticator, self).__init__(**conf) self.keys_url = conf.get('keys_url', None) self.scope = conf.get('scope', 'openid profile') + self.load_user_info = any_to_bool( + conf.get('load_user_info', True)) def get_key(self, key_id): keys_url = self.keys_url @@ -235,6 +259,7 @@ class OpenIDConnectAuthenticator(JWTAuthenticator): def get_capabilities(self): d = super(OpenIDConnectAuthenticator, self).get_capabilities() d[self.realm]['scope'] = self.scope + d[self.realm]['load_user_info'] = self.load_user_info return d def _decode(self, rawToken):