Repository: techmatters/terraso-backend

Issue:
Use client certificate encoded JWT for Microsoft SSO instead of client secret
## Description

Microsoft suggests using a [client certificate](https://learn.microsoft.com/en-us/azure/active-directory/develop/active-directory-certificate-credentials) at the ["request access token"](https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow#request-an-access-token-with-a-certificate-credential) step of OAuth2. The client secret is replaced by a JWT that is encrypted with our own private certificate. As this adds an extra layer of security, it would be best to implement it this way.



Discussion:

Resolving patch:
diff --git a/terraso_backend/apps/auth/providers.py b/terraso_backend/apps/auth/providers.py
--- a/terraso_backend/apps/auth/providers.py
+++ b/terraso_backend/apps/auth/providers.py
@@ -14,6 +14,7 @@
 # along with this program. If not, see https://www.gnu.org/licenses/.
 
 import urllib
+import uuid
 from datetime import timedelta
 
 import httpx
@@ -158,6 +159,11 @@ class MicrosoftProvider:
     CLIENT_ID = settings.MICROSOFT_CLIENT_ID
     CLIENT_SECRET = settings.MICROSOFT_CLIENT_SECRET
     REDIRECT_URI = f"{settings.API_ENDPOINT}/auth/microsoft/callback"
+    PRIVATE_KEY = settings.MICROSOFT_PRIVATE_KEY
+    CERTIFICATE_THUMBPRINT = settings.MICROSOFT_CERTIFICATE_THUMBPRINT
+
+    JWT_AUD = TOKEN_URI
+    JWT_ALG = "RS256"
 
     @classmethod
     def login_url(cls, state=None):
@@ -165,7 +171,7 @@ def login_url(cls, state=None):
             client_id=cls.CLIENT_ID,
             response_type="code",
             redirect_uri=cls.REDIRECT_URI,
-            scope="email profile openid",
+            scope="email profile openid offline_access",
             response_mode="form_post",
         )
         if state:
@@ -186,14 +192,43 @@ def _handle_exceptions(exc):
                 error = "response_error"
         return Tokens.from_microsoft(dict(error=error, error_description=error_msg))
 
+    def _build_client_secret(self):
+        """docs: https://learn.microsoft.com/en-us/azure/active-directory/"""
+        """develop/active-directory-certificate-credentials#claims-payload"""
+
+        current_time = timezone.now()
+        claims = {
+            "aud": self.JWT_AUD,
+            "exp": current_time + timedelta(minutes=15),
+            "iss": self.CLIENT_ID,
+            "jti": str(uuid.uuid4()),
+            "nbf": current_time,
+            "sub": self.CLIENT_ID,
+            "iat": current_time,
+        }
+
+        jwt_header = {
+            "alg": self.JWT_ALG,
+            "typ": "JWT",
+            "x5t": self.CERTIFICATE_THUMBPRINT,
+        }
+
+        return jwt.encode(
+            payload=claims,
+            key=self.PRIVATE_KEY,
+            algorithm=self.JWT_ALG,
+            headers=jwt_header,
+        )
+
     def fetch_auth_tokens(self, authorization_code):
+        client_assertion = self._build_client_secret()
         params = dict(
             client_id=self.CLIENT_ID,
             code=authorization_code,
             grant_type="authorization_code",
             redirect_uri=self.REDIRECT_URI,
-            client_secret=self.CLIENT_SECRET,
-            # scope="openid email profile",
+            client_assertion=client_assertion,
+            client_assertion_type="urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
         )
         try:
             resp = httpx.post(self.TOKEN_URI, data=params)
diff --git a/terraso_backend/config/settings.py b/terraso_backend/config/settings.py
--- a/terraso_backend/config/settings.py
+++ b/terraso_backend/config/settings.py
@@ -12,7 +12,7 @@
 #
 # You should have received a copy of the GNU Affero General Public License
 # along with this program. If not, see https://www.gnu.org/licenses/.
-
+import base64
 import os
 
 import django
@@ -232,6 +232,10 @@
 
 MICROSOFT_CLIENT_ID = config("MICROSOFT_CLIENT_ID", default="")
 MICROSOFT_CLIENT_SECRET = config("MICROSOFT_CLIENT_SECRET", default="")
+MICROSOFT_PRIVATE_KEY = config("MICROSOFT_PRIVATE_KEY", default="").strip()
+MICROSOFT_CERTIFICATE_THUMBPRINT = base64.b64encode(
+    bytes.fromhex(config("MICROSOFT_CERTIFICATE_THUMBPRINT", default="").strip())
+).decode("utf-8")
 
 JWT_SECRET = config("JWT_SECRET")
 JWT_ALGORITHM = config("JWT_ALGORITHM", default="HS512")


Test patch: