Client applications

If you are writing a client application, you will probably want to test the nominal authentication case, i.e. the case when the users successfully log in and give their consent to your application. Depending on your implementation, you might also need to test how your application behaves in case of error during the authentication process.

You can also test how your application deals with OIDC registration or refresh token exchange.

pytest-iam will help you set up some of those scenarios in your tests.

Setting up your test

Start by configuring your application so it uses pytest-iam as identity provider. This would probably be something in the fashion of:

conftest.py
@pytest.fixture
def app(iam_server):
    return create_app(
        config={
            "SERVER_NAME": "myclient.test",
            "SECRET_KEY": str(uuid.uuid4()),
            "OAUTH_SERVER": iam_server.url,
        }
    )

Users & groups

You can use the available User and Group models to set up the IAM server for your tests. Optionally you can put them in pytest fixtures so they are reusable:

@pytest.fixture
def user(iam_server):
    user = iam_server.models.User(
        user_name="user",
        emails=["email@example.org"],
        password="password",
    )
    iam_server.backend.save(user)
    yield user
    iam_server.backend.delete(user)

@pytest.fixture
def group(iam_server, user):
    group = iam_server.models.Group(
        display_name="group",
        members=[user],
    )
    iam_server.backend.save(group)
    yield group
    iam_server.backend.delete(group)

If you don’t care about the data your users and group, you can use the available random generation utilities.

@pytest.fixture
def user(iam_server):
    user = iam_server.random_user()
    iam_server.backend.save(user)
    yield user
    iam_server.backend.delete(user)

@pytest.fixture
def group(iam_server, user):
    group = iam_server.random_group()
    group.members = group.members + [user]
    iam_server.backend.save(group)
    yield group
    iam_server.backend.delete(group)

OIDC Client registration

Before your application can authenticate against the IAM server, it must register and provide details such as the allowed redirection URIs. To achieve this you can use the Client model. Let us suppose your application have a /authorize endpoint for the authorization code - token exchange:

@pytest.fixture
def client(iam_server):
    inst = iam_server.models.Client(
        client_id="client_id",
        client_secret="client_secret",
        client_name="My Application",
        client_uri="http://example.org",
        redirect_uris=["http://example.org/authorize"],
        grant_types=["authorization_code"],
        response_types=["code", "token", "id_token"],
        token_endpoint_auth_method="client_secret_basic",
        scope=["openid", "profile", "groups"],
    )
    iam_server.backend.save(inst)
    yield inst
    iam_server.backend.delete(inst)

Note

The IAM server has a TRUSTED_HOSTS parameter. If its value matches the client_uri, end-users won’t be showed a consent page when the client redirect them to the IAM authorization page.

Note that the IAM implements the OAuth2/OIDC dynamic client registration protocol, thus you might not need a client fixture if your application dynamically register one. No initial token is needed to use dynamic client registration. Here is an example of dynamic registration you can implement in your application:

response = iam_server.test_client.post(
    "/oauth/register",
    json={
        "client_name": "My application",
        "client_uri": "http://example.org",
        "redirect_uris": ["http://example.org/authorize"],
        "grant_types": ["authorization_code"],
        "response_types": ["code", "token", "id_token"],
        "token_endpoint_auth_method": "client_secret_basic",
        "scope": "openid profile groups",
    },
)
client_id = response.json["client_id"]
client_secret = response.json["client_secret"]

Nominal authentication workflow

Let us suppose that your application have a /protected endpoint that redirects users to the IAM server if unauthenticated. We suppose that you have a test_client fixture like werkzeug Client that allows to test your application endpoints without real HTTP requests. pytest-iam provides its own test client, available with test_client(). Let us see how to implement an authorization_code authentication test case:

Full login and consent workflow to get an access token
 def test_login_and_consent(iam_server, client, user, test_client):
     # 1. attempt to access a protected page
     res = test_client.get("/protected")

     # 2. redirect to the authorization server login page
     res = iam_server.test_client.get(res.location)

     # 3. fill the 'login' form at the IAM
     res = iam_server.test_client.post(res.location, data={"login": "user"})

     # 4. fill the 'password' form at the IAM
     res = iam_server.test_client.post(
         res.location, data={"password": "correct horse battery staple"}
     )

     # 5. fill the 'consent' form at the IAM
     res = iam_server.test_client.post(res.location, data={"answer": "accept"})

     # 6. load your application authorization endpoint
     res = test_client.get(res.location)

     # 7. now you have access to the protected page
     res = test_client.get("/protected")

What happened?

  1. A simulation of an access to a protected page on your application. As the page is protected, it returns a redirection to the IAM login page.

  2. The IAM test client loads the login page and get redirected to the login form.

  3. The login form is filled, and returns a redirection to the password form.

  4. The password form is filled, and returns a redirection to the consent form.

  5. The consent form is filled, and return a redirection to your application authorization endpoint with a OAuth code grant.

  6. You client authorization endpoint is loaded, it reaches the IAM and exchanges the code grant with a token. This is generally where you fill the session to keep users logged in.

  7. The protected page is loaded, and now you should be able to access it.

Steps 2, 3 and 4 can be quite redundant, so pytest-iam provides shortcuts with the login() and consent() methods. They allow you to skip the login, password and consent pages:

Fast login and consent workflow to get an access token
 def test_login_and_consent(iam_server, client, user, test_client):
     iam_server.login(user)
     iam_server.consent(user)

     # 1. attempt to access a protected page
     res = test_client.get("/protected")

     # 2. authorization code request
     res = iam_server.test_client.get(res.location)

     # 3. load your application authorization endpoint
     res = test_client.get(res.location)

     # 4. now you have access to the protected page
     res = test_client.get("/protected")

Authentication workflow errors

The OAuth2 and the OpenID Connect specifications details how things might go wrong:

The OAuth2 error codes:

  • invalid_request

  • unauthorized_client

  • access_denied

  • unsupported_response_type

  • invalid_scope

  • server_error

  • temporarily_unavailable

The OIDC error codes:

  • interaction_required

  • login_required

  • account_selection_required

  • consent_required

  • invalid_request_uri

  • invalid_request_object

  • request_not_supported

  • request_uri_not_supported

  • registration_not_supported

You might or might not be interested in testing how your application behaves when it encounters those situations, depending on the situation and how much you trust the libraries that helps your application perform the authentication process.

Account creation workflow

The Initiating User Registration via OpenID Connect 1.0 specification details how to initiate an account creation workflow at the IAM by setting the prompt=create authorization request parameter.

In the following example, we suppose that the /create endpoint redirects to the IAM authorization endpoint with the prompt=create parameters.

Account creation workflow
def test_account_creation(iam_server, client, test_client):
    # access to the client account creation page
    res = test_client.get("/create")

    # redirection to the IAM account creation page
    res = iam_server.test_client.get(res.location)

    # redirection to the account creation page
    res = iam_server.test_client.get(res.location)

    payload = {
        "user_name": "user",
        "given_name": "John",
        "family_name": "Doe",
        "emails-0": "email@example.com",
        "preferred_language": "auto", # appears to be mandatory
        "password1": "correct horse battery staple",
        "password2": "correct horse battery staple",
    }

    # fill the registration form
    res = iam_server.test_client.post(res.location, data=payload)

    # fill the 'consent' form
    res = iam_server.test_client.post(res.location, data={"answer": "accept"})

    # return to the client with a code
    res = test_client.get(res.location)

    assert "User account successfully created" in res.text

Unfortunately there is no helpers for account creation in the fashion of login().

Provisioning

The iam_server instance provides a SCIM2 provisioning API at the address /scim/v2. You can use it to update your user profiles directly at the IAM. You can have a look to the Canaille documentation to see implementation details.

To perform SCIM requests you might be interested in tools such as scim2-client.