Skip to content

Commit 69ceb8c

Browse files
authored
Merge pull request #4 from pytest-dev/create-prompt
Implement an embedded test client
2 parents 78275a0 + 83def2a commit 69ceb8c

File tree

10 files changed

+270
-84
lines changed

10 files changed

+270
-84
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
Versions follow [Semantic Versioning](https://semver.org/>) (<major>.<minor>.<patch>).
99

10+
## [0.2.0] - Unreleased
11+
12+
### Added
13+
14+
- Implement `test_client` attribute.
15+
- Implement `logout` method.
16+
- Support for OIDC `create` prompt.
17+
1018
## [0.1.1] - 2025-04-04
1119

1220
### Changed

README.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,19 +36,19 @@ def test_authentication(iam_server, testapp, client):
3636
# create a random user on the IAM server
3737
user = iam_server.random_user()
3838

39-
# logs the user in give its consent to your application
40-
iam_server.login(user)
41-
iam_server.consent(user)
39+
# 1. attempt to access a protected page, returns a redirection to the IAM
40+
res = test_client.get("/protected")
4241

43-
# simulate an attempt to access a protected page of your app
44-
response = testapp.get("/protected", status=302)
42+
# 2. authorization code request
43+
res = iam_server.test_client.get(res.location)
4544

46-
# get an authorization code request at the IAM
47-
res = requests.get(res.location, allow_redirects=False)
45+
# 3. load your application authorization endpoint
46+
res = test_client.get(res.location)
4847

49-
# access to the redirection URI
50-
res = testclient.get(res.headers["Location"])
51-
res.mustcontain("Hello World!")
48+
# 4. now you have access to the protected page
49+
res = test_client.get("/protected")
50+
51+
assert "Hello, world!" in res.text
5252
```
5353

5454
Check the [client application](https://pytest-iam.readthedocs.io/en/latest/client-applications.html) or

doc/client-applications.rst

Lines changed: 108 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,8 @@ client registration. Here is an example of dynamic registration you can implemen
9494

9595
.. code:: python
9696
97-
response = requests.post(
98-
f"{iam_server.url}/oauth/register",
97+
response = iam_server.test_client.post(
98+
"/oauth/register",
9999
json={
100100
"client_name": "My application",
101101
"client_uri": "http://example.org",
@@ -106,56 +106,82 @@ client registration. Here is an example of dynamic registration you can implemen
106106
"scope": "openid profile groups",
107107
},
108108
)
109-
client_id = response.json()["client_id"]
110-
client_secret = response.json()["client_secret"]
109+
client_id = response.json["client_id"]
110+
client_secret = response.json["client_secret"]
111111
112-
Nominal authentication case
113-
---------------------------
112+
Nominal authentication workflow
113+
-------------------------------
114114

115115
Let us suppose that your application have a ``/protected`` that redirects users
116-
to the IAM server if unauthenticated. With your :class:`~canaille.core.models.User`
117-
and :class:`~canaille.oidc.models.Client` fixtures, you can use the
118-
:meth:`~pytest_iam.Server.login` and :meth:`~pytest_iam.Server.consent` methods
119-
to skip the login and the consent page from the IAM.
120-
116+
to the IAM server if unauthenticated.
121117
We suppose you have a test client fixture like werkzeug :class:`~werkzeug.test.Client`
122-
that allows to test your application endpoints without real HTTP requests. Let
123-
us see how to implement an authorization_code authentication test case:
118+
that allows to test your application endpoints without real HTTP requests.
119+
pytest-iam provides its own test client, available with :meth:`~pytest_iam.Server.test_client`.
120+
Let us see how to implement an authorization_code authentication test case:
124121

125-
.. code:: python
122+
.. code-block:: python
123+
:caption: Full login and consent workflow to get an access token
124+
125+
def test_login_and_consent(iam_server, client, user, test_client):
126+
# 1. attempt to access a protected page
127+
res = test_client.get("/protected")
128+
129+
# 2. redirect to the authorization server login page
130+
res = iam_server.test_client.get(res.location)
131+
132+
# 3. fill the 'login' form at the IAM
133+
res = iam_server.test_client.post(res.location, data={"login": "user"})
134+
135+
# 4. fill the 'password' form at the IAM
136+
res = iam_server.test_client.post(
137+
res.location, data={"password": "correct horse battery staple"}
138+
)
139+
140+
# 5. fill the 'consent' form at the IAM
141+
res = iam_server.test_client.post(res.location, data={"answer": "accept"})
142+
143+
# 6. load your application authorization endpoint
144+
res = test_client.get(res.location)
145+
146+
# 7. now you have access to the protected page
147+
res = test_client.get("/protected")
148+
149+
What happened?
150+
151+
1. A simulation of an access to a protected page on your application. As the page is protected,
152+
it returns a redirection to the IAM login page.
153+
2. The IAM test client loads the login page and get redirected to the login form.
154+
3. The login form is filled, and returns a redirection to the password form.
155+
4. The password form is filled, and returns a redirection to the consent form.
156+
5. The consent form is filled, and return a redirection to your application authorization endpoint with a OAuth code grant.
157+
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.
158+
7. The protected page is loaded, and now you should be able to access it.
159+
160+
Steps 2, 3 and 4 can be quite redundant, so pytest-iam provides shortcuts with the
161+
:meth:`~pytest_iam.Server.login` and :meth:`~pytest_iam.Server.consent` methods.
162+
They allow you to skip the login, password and consent pages:
163+
164+
.. code-block:: python
165+
:caption: Fast login and consent workflow to get an access token
126166
127-
def test_login_and_consent(iam_server, client, user, testclient):
167+
def test_login_and_consent(iam_server, client, user, test_client):
128168
iam_server.login(user)
129169
iam_server.consent(user)
130170
131171
# 1. attempt to access a protected page
132-
res = testclient.get("/protected", status=302)
172+
res = test_client.get("/protected")
133173
134174
# 2. authorization code request
135-
res = requests.get(res.location, allow_redirects=False)
175+
res = iam_server.test_client.get(res.location)
136176
137177
# 3. load your application authorization endpoint
138-
res = testclient.get(res.headers["Location"], status=302)
178+
res = test_client.get(res.location)
139179
140-
# 4. redirect to the protected page
141-
res = res.follow(status=200)
180+
# 4. now you have access to the protected page
181+
res = test_client.get("/protected")
142182
143-
What happened?
144-
145-
1. A simulation of an access to a protected page on your application.
146-
2. That redirects to the IAM authorization endpoint. Since the users are already
147-
logged and their consent already given, the IAM redirects to your application
148-
authorization configured redirect_uri, with the authorization code passed in
149-
the query string. Note that ``requests`` is used in this example to perform
150-
the request. Indeed, generally testclient such as the werkzeug one cannot
151-
perform real HTTP requests.
152-
3. Access your application authorization endpoint that will exchange the
153-
authorization code against a token and check the user credentials.
154-
4. For instance, your application can redirect the users back to the page
155-
they attempted to access in the first place.
156-
157-
Error cases
158-
-----------
183+
Authentication workflow errors
184+
------------------------------
159185

160186
The `OAuth2 <https://datatracker.ietf.org/doc/html/rfc6749>`_ and the `OpenID Connect <https://openid.net/specs/openid-connect-core-1_0.html>`_ specifications details how things might go wrong:
161187

@@ -183,3 +209,49 @@ The `OIDC error codes <https://openid.net/specs/openid-connect-core-1_0.html#Aut
183209

184210
You might or might not be interested in testing how your application behaves when it encounters those situations,
185211
depending on the situation and how much you trust the libraries that helps your application perform the authentication process.
212+
213+
Account creation workflow
214+
-------------------------
215+
216+
The `Initiating User Registration via OpenID Connect 1.0 <https://openid.net/specs/openid-connect-prompt-create-1_0.html>`_
217+
specification details how to initiate an account creation workflow at the IAM
218+
by setting the ``prompt=create`` authorization request parameter.
219+
220+
In the following example, we suppose that the ``/create`` endpoint redirects
221+
to the IAM authorization endpoint with the ``prompt=create`` parameters.
222+
223+
.. code-block:: python
224+
:caption: Account creation workflow
225+
226+
def test_account_creation(iam_server, client, test_client):
227+
# access to the client account creation page
228+
res = test_client.get("/create")
229+
230+
# redirection to the IAM account creation page
231+
res = iam_server.test_client.get(res.location)
232+
233+
# redirection to the account creation page
234+
res = iam_server.test_client.get(res.location)
235+
236+
payload = {
237+
"user_name": "user",
238+
"given_name": "John",
239+
"family_name": "Doe",
240+
"emails-0": "email@example.com",
241+
"preferred_language": "auto", # appears to be mandatory
242+
"password1": "correct horse battery staple",
243+
"password2": "correct horse battery staple",
244+
}
245+
246+
# fill the registration form
247+
res = iam_server.test_client.post(res.location, data=payload)
248+
249+
# fill the 'consent' form
250+
res = iam_server.test_client.post(res.location, data={"answer": "accept"})
251+
252+
# return to the client with a code
253+
res = test_client.get(res.location)
254+
255+
assert "User account successfully created" in res.text
256+
257+
Unfortunately there is no helpers for account creation in the fashion of :meth:`~pytest_iam.Server.login`.

doc/resource-servers.rst

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,16 @@ request against the identity server token introspection endpoint.
88

99
.. code:: python
1010
11-
def test_valid_token_auth(iam_server, testclient, client, user):
11+
def test_valid_token_auth(iam_server, test_client, client, user):
1212
token = iam_server.random_token(client=client, subject=user)
13-
res = testclient.get(
13+
res = test_client.get(
1414
"/protected-resource", headers={"Authorization": f"Bearer {token.access_token}"}
1515
)
1616
assert res.status_code == 200
1717
1818
19-
def test_invalid_token_auth(iam_server, testclient):
20-
res = testclient.get(
19+
def test_invalid_token_auth(iam_server, test_client):
20+
res = test_client.get(
2121
"/protected-resource", headers={"Authorization": "Bearer invalid"}
2222
)
2323
assert res.status_code == 401

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ documentation = "https://pytest-iam.readthedocs.io/en/latest/"
3131

3232
requires-python = ">=3.10"
3333
dependencies = [
34-
"canaille[oidc]>=0.0.71",
34+
"canaille[oidc]>=0.0.74",
3535
"portpicker>=1.6.0",
3636
"pytest>=7.0.0",
3737
"faker>=21.0.0",

pytest_iam/__init__.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from canaille.oidc.installation import generate_keypair
2121
from flask import Flask
2222
from flask import g
23+
from werkzeug.test import Client
2324

2425

2526
class Server:
@@ -31,6 +32,9 @@ class Server:
3132
app: Flask
3233
"""The authorization server flask app."""
3334

35+
test_client: Client
36+
"""A test client to interact with the IAM without performing real network requests."""
37+
3438
models: ModuleType
3539
"""The module containing the available model classes."""
3640

@@ -40,8 +44,9 @@ class Server:
4044
logging: bool = False
4145
"""Whether the request access log is enabled."""
4246

43-
def __init__(self, app, port: int, backend: Backend, logging: bool = False):
47+
def __init__(self, app: Flask, port: int, backend: Backend, logging: bool = False):
4448
self.app = app
49+
self.test_client = app.test_client()
4550
self.backend = backend
4651
self.port = port
4752
self.logging = logging
@@ -50,11 +55,25 @@ def __init__(self, app, port: int, backend: Backend, logging: bool = False):
5055
)
5156
self.models = models
5257
self.logged_user = None
58+
self.login_datetime = None
5359

5460
@self.app.before_request
5561
def logged_user():
5662
if self.logged_user:
5763
g.user = self.logged_user
64+
else:
65+
try:
66+
del g.user
67+
except AttributeError:
68+
pass
69+
70+
if self.login_datetime:
71+
g.last_login_datetime = self.login_datetime
72+
else:
73+
try:
74+
del g.last_login_datetime
75+
except AttributeError:
76+
pass
5877

5978
def make_request_handler(self):
6079
server = self
@@ -125,6 +144,12 @@ def login(self, user):
125144
This allows to skip the connection screen.
126145
"""
127146
self.logged_user = user
147+
self.login_datetime = datetime.datetime.now(datetime.timezone.utc)
148+
149+
def logout(self):
150+
"""Close the current user session if existing."""
151+
self.logged_user = None
152+
self.login_datetime = None
128153

129154
def consent(self, user, client=None):
130155
"""Make a user consent to share data with OIDC clients.
@@ -172,6 +197,7 @@ def iam_configuration(tmp_path_factory, iam_server_port) -> dict[str, Any]:
172197
"WTF_CSRF_ENABLED": False,
173198
"SERVER_NAME": f"localhost:{iam_server_port}",
174199
"CANAILLE": {
200+
"ENABLE_REGISTRATION": True,
175201
"JAVASCRIPT": False,
176202
"ACL": {
177203
"DEFAULT": {

tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def user(iam_server):
2525
user_name="user",
2626
formatted_name="John Doe",
2727
emails=["email@example.com"],
28-
password="password",
28+
password="correct horse battery staple",
2929
)
3030
iam_server.backend.save(inst)
3131
yield inst

0 commit comments

Comments
 (0)