cheslijones
cheslijones

Reputation: 9194

How do I send credentials so I can unit test MSAL for Python?

The flow to this admin app is basically:

  1. User goes to /admin.
  2. @azure/msal-react checks if the user is logged in and if not redirects them to login.
  3. When the accessToken, idToken, and oid have been received on the FE, they are sent to my API (/api) where they are validated.
  4. If the tokens are validated, and verified as belonging to the oid that came from the FE, then a JWT is issued for the user indicating they are authenticated with the API as well.
  5. The JWT is sent back to the FE where having both the MSAL isAuthenticated() and their API JWT isApiAuthenticated() indicates they are fully authenticated and it renders the FE components.

I'm trying to write unit tests for the what I have in the aad.py (bottom).

The issue I'm seeing is that I need to retrieve and send it a legit MSAL access_token, id_token, and oid. That means I actually need to log in a user against our AAD.

I do have a dummy user to use for this purpose, but I'm not seeing in the Python MSAL documentation how to sign the user in without interaction and just submit username and password.

Suggestions for how to write unit tests to test? Do I need to mockup something else, or should I be able to send a dummy user's username and password with Python MSAL?

# Python libraries
import json

# Third-party dependencies
import jwt
import requests
from base64 import b64decode, b64encode
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization

from config import settings


def ms_graph(access_token):
    """
    Takes the access_token from the FE and uses it query MS Graph for user
    information.

    Returns an object.

    Takes one argument:
    access_token: sent from FE.
    """
    graph_response = requests.get(  # Use token to call downstream service
        settings.GRAPH_URI,
        headers={'Authorization': 'Bearer ' + access_token},)
    return json.loads(graph_response.text)


def validate_token(token):
    """
    Used to decode the token sent from the FE. The result is
    a verified signature that contains information about the user and tokens.

    Returns an object.

    Takes one argument:
    token: The idToken sent from the FE that needs to be validated.
    """
    # Get a list of the possible public keys from the JWKS_URI endpoint
    jwkeys = requests.get(settings.JWKS_URI).json()['keys']

    # Extract the 'kid' from the unverified header to get the public key
    token_key_id = jwt.get_unverified_header(token)['kid']

    # Get the object corresponding to the 'kid' key
    jwk = [key for key in jwkeys if key['kid'] == token_key_id][0]

    # Encode the 'x5c' to eventually get the public key for decoding
    der_cert = b64decode(jwk['x5c'][0])
    cert = x509.load_der_x509_certificate(der_cert, default_backend())
    public_key = cert.public_key()
    pem_key = public_key.public_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PublicFormat.SubjectPublicKeyInfo
    )

    # Finally, validate and decode the token
    token_claims = jwt.decode(
        token,
        pem_key,
        algorithms=['RS256'],
        audience=settings.CLIENT_ID
    )

    return token_claims


def validate_user(access_token, id_token, client_oid):
    """
    Takes the id_token sent from the FE and validates the signature.
    The validated signature also contains an oid claim which is used to verify
    the oid sent from the FE.

    Returns an object containing:
    {
        "user_validated": bool,
        "graph_response": object
    }

    Takes two arguments:
    access_token: access_token sent from the FE.
    id_token: the id_token sent from the FE.
    client_oid: the oid sent from the FE.
    """
    # Decode the token to retrieve claims
    id_token_payload = validate_token(id_token)

    # Check if the claim oid and matches the oid sent from the FE
    if id_token_payload['oid'] == client_oid:
        graph_response = ms_graph(access_token)
        res = dict()
        res['user_validated'] = graph_response['id'] == client_oid
        res['graph_response'] = graph_response
        return res

Upvotes: 1

Views: 2454

Answers (1)

void
void

Reputation: 2799

If you really really want to obtain tokens for unit tests with username and password then look at acquire_token_by_username_password() and its example in MS docs.

But there are reasons to not let unit tests use the real MS Graph API:

  • dependency on network connection can make unit tests run slow or fail, even if your code still works fine,
  • storing credentials in repository is dangerous, keeping them properly is difficult,
  • the dummy account can be locked,
  • numerous test calls can cause throttling,
  • it is kind of unpolite to abuse the infrastructure with non-productive requests.

The suggestion for unit tests is usual: isolate test cases, then mock and patch any external dependencies not related to code being tested.

MSAL uses requests for calling the cloud, and there is a great package for mocking its work: meet responses. Prevent MSAL for sending out any requests and mock OpenID configuration call:

import responses
import time
import unittest


MS_OPENID_CONFIG = {
    "authorization_endpoint": "https://login.microsoftonline.com/common/"
                              "oauth2/v2.0/authorize",
    "token_endpoint": "https://login.microsoftonline.com/common/oauth2/"
                              "v2.0/token",
}

MS_TOKENS = {
    'id_token_claims': {
        'exp': time.time() + 60,
        'oid': '00000000-0000-0000-0000-000000000000',
        'upn': 'test_user_1_'
    },
    'id_token': {},
    'access_token': {}
}

class BaseTest(unittest.TestCase):

    def afterSetUp(self):
        self.responses = responses.RequestsMock()
        self.responses.start()
        self.responses.add(
            responses.GET,
            'https://login.microsoftonline.com/common/v2.0/'
            '.well-known/openid-configuration',
            json=MS_OPENID_CONFIG
        )
        # add_calback() allows modifying mock token in runtime
        self.responses.add_callback(
            responses.POST,
            'https://login.microsoftonline.com/common/oauth2/v2.0/token',
            callback=lambda request: (200, {}, json.dumps(MS_TOKENS)),
            content_type='application/json',
        )
        self.addCleanup(self.responses.stop)
        self.addCleanup(self.responses.reset)

For testing validate_token() you can bundle pre-generated test RSA key pair:

openssl genrsa -out test_rsa_private.pem 512
openssl rsa -in test_rsa_private.pem -pubout -outform PEM -out test_rsa_public.pem

Then you can mock response to settings.JWKS_URI in the same way as OpenID config, returning test public key with known kid.

If you'd prefer to save time on signing test tokens, then just mock jwt signature verification to always return True:

from unittest import Mock, patch


    def test_validate_token(self)
        ...
        with patch('jwt.algorithms.RSAAlgorithm.verify', new=Mock(return_value=True)):
            # jwt.decode(at, key=open('test_rsa_public.pem').read(), algorithms=['RS256'], options={'verify_exp': False, 'verify_aud': False})
            validate_token(test_token)

And so on, unit test should only test the code of unit itself, everything around can be mocked as needed. Read the source of MSAL and jwt. Don't mock what looks like implementation details, they might change in future versions.

Upvotes: 2

Related Questions