From 5826a2a6c8bdd3cb57decf67b601e7ac9f7353a4 Mon Sep 17 00:00:00 2001 From: "James E. Blair" Date: Fri, 11 Feb 2022 10:51:33 -0800 Subject: [PATCH] Add support for Microsoft login Microsoft login (or Azure AD) nearly supports the OpenID Connect protocol. With extensive configuration and some additional customization of the values expected in the tokens, it will work with Zuul. Add the necessary additional configuration and a HOWTO with the procedure. Change-Id: I445a0494796572762fbf78e580d7d6dd17be7239 --- doc/source/authentication.rst | 1 + doc/source/configuration.rst | 22 +++++ doc/source/howtos/openid-with-microsoft.rst | 84 +++++++++++++++++++ .../notes/ms-login-5be892f3c151bfe7.yaml | 5 ++ tests/unit/test_web.py | 2 + web/src/actions/auth.js | 2 + web/src/reducers/auth.js | 1 + zuul/driver/auth/jwt.py | 43 ++++++++-- 8 files changed, 151 insertions(+), 9 deletions(-) create mode 100644 doc/source/howtos/openid-with-microsoft.rst create mode 100644 releasenotes/notes/ms-login-5be892f3c151bfe7.yaml 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):