Merge branch '207-client-credentials-grant' into 'main'

implement OIDC client_credentials flow

Closes #207

See merge request yaal/canaille!196
This commit is contained in:
Éloi Rivard 2024-12-06 14:12:16 +00:00
commit f4fe5f2285
12 changed files with 114 additions and 44 deletions

View file

@ -8,6 +8,7 @@ Added
:attr:`~canaille.core.configuration.CoreSettings.ENABLE_PASSWORD_COMPROMISSION_CHECK` and
:attr:`~canaille.core.configuration.CoreSettings.API_URL_HIBP`
:issue:`179`
- Implement OIDC client_credentials flow. :issue:`207`
Changed
^^^^^^^

View file

@ -238,7 +238,7 @@ class Token(canaille.oidc.models.Token, Base, SqlAlchemyModel):
access_token: Mapped[str] = mapped_column(String, nullable=True)
client_id: Mapped[str] = mapped_column(ForeignKey("client.id"))
client: Mapped["Client"] = relationship()
subject_id: Mapped[str] = mapped_column(ForeignKey("user.id"))
subject_id: Mapped[str] = mapped_column(ForeignKey("user.id"), nullable=True)
subject: Mapped["User"] = relationship()
type: Mapped[str] = mapped_column(String, nullable=True)
refresh_token: Mapped[str] = mapped_column(String, nullable=True)

View file

@ -82,6 +82,7 @@ class ClientAddForm(Form):
("implicit", "implicit"),
("hybrid", "hybrid"),
("refresh_token", "refresh_token"),
("client_credentials", "client_credentials"),
],
default=["authorization_code", "refresh_token"],
)

View file

@ -192,7 +192,9 @@ def authorize_consent(client, user):
@csrf.exempt
def issue_token():
request_params = request.form.to_dict(flat=False)
grant_type = request_params["grant_type"][0]
grant_type = (
request_params["grant_type"][0] if request_params["grant_type"] else None
)
current_app.logger.debug("token endpoint request: POST: %s", request_params)
response = authorization.create_token_response()
current_app.logger.debug("token endpoint response: %s", response.json)
@ -201,9 +203,15 @@ def issue_token():
access_token = response.json["access_token"]
token = Backend.instance.get(models.Token, access_token=access_token)
request_ip = request.remote_addr or "unknown IP"
if token.subject:
current_app.logger.security(
f"Issued {grant_type} token for {token.subject.user_name} in client {token.client.client_name} from {request_ip}"
)
else:
current_app.logger.security(
f"Issued {grant_type} token for client {token.client.client_name} from {request_ip}"
)
return response

View file

@ -509,6 +509,11 @@ require_oauth = ResourceProtector()
def generate_access_token(client, grant_type, user, scope):
if grant_type == "client_credentials":
# Canaille could generate a JWT with iss/sub/aud/exp/iat/jti/scope/client_id
# instead of a random string
return gen_salt(48)
audience = [client.client_id for client in client.audience]
bearer_token_generator = authorization._token_generators["default"]
kwargs = {

View file

@ -22,9 +22,15 @@
</a>
</td>
<td>
{% if token.subject %}
<a href="{{ url_for("core.account.profile_edition", edited_user=token.subject) }}">
{{ token.subject.user_name }}
</a>
{% else %}
<a href="{{ url_for('oidc.clients.edit', client=token.client) }}">
{{ token.client.client_name }}
</a>
{% endif %}
</td>
<td>{{ token.issue_date }}</td>
</tr>

View file

@ -52,9 +52,15 @@
<tr>
<td>{{ _("Subject") }}</td>
<td>
{% if token.subject %}
<a href="{{ url_for("core.account.profile_edition", edited_user=token.subject) }}">
{{ token.subject.identifier }}
</a>
{% else %}
<a href="{{ url_for("oidc.clients.edit", client=token.client) }}">
{{ token.client.client_name }}
</a>
{% endif %}
</td>
</tr>
<tr>
@ -122,7 +128,7 @@
<tr>
<td>{{ _("Refresh token") }}</td>
<td class="ui fluid action input">
<input type="text" value="{{ token.refresh_token }}" readonly class="copy-text" id="refresh-token" data-copy="refresh-token">
<input type="text" value="{{ token.refresh_token or "" }}" readonly class="copy-text" id="refresh-token" data-copy="refresh-token" {% if not token.refresh_token %}placeholder="{% trans %}No refresh token{% endtrans %}"{% endif %}>
<button class="ui primary right labeled icon button copy-button" data-copy="refresh-token">
<i class="copy icon"></i>
{% trans %}Copy{% endtrans %}

View file

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2024-12-05 13:23+0100\n"
"POT-Creation-Date: 2024-12-06 14:57+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -806,8 +806,8 @@ msgid ""
msgstr ""
#: canaille/core/templates/invite.html:74
#: canaille/oidc/templates/token_view.html:117
#: canaille/oidc/templates/token_view.html:128
#: canaille/oidc/templates/token_view.html:123
#: canaille/oidc/templates/token_view.html:134
msgid "Copy"
msgstr ""
@ -1468,7 +1468,7 @@ msgstr ""
#: canaille/core/templates/partial/users.html:73
#: canaille/oidc/templates/partial/authorization_list.html:31
#: canaille/oidc/templates/partial/client_list.html:35
#: canaille/oidc/templates/partial/token_list.html:39
#: canaille/oidc/templates/partial/token_list.html:46
msgid "No item matches your request"
msgstr ""
@ -1477,7 +1477,7 @@ msgstr ""
#: canaille/core/templates/partial/users.html:75
#: canaille/oidc/templates/partial/authorization_list.html:33
#: canaille/oidc/templates/partial/client_list.html:37
#: canaille/oidc/templates/partial/token_list.html:41
#: canaille/oidc/templates/partial/token_list.html:48
msgid "Maybe try with different criteria?"
msgstr ""
@ -1486,7 +1486,7 @@ msgstr ""
#: canaille/core/templates/partial/users.html:78
#: canaille/oidc/templates/partial/authorization_list.html:36
#: canaille/oidc/templates/partial/client_list.html:40
#: canaille/oidc/templates/partial/token_list.html:44
#: canaille/oidc/templates/partial/token_list.html:51
#: canaille/oidc/templates/preconsent_list.html:91
msgid "There is nothing here"
msgstr ""
@ -1581,60 +1581,60 @@ msgstr ""
msgid "Grant types"
msgstr ""
#: canaille/oidc/endpoints/forms.py:89
#: canaille/oidc/templates/token_view.html:61
#: canaille/oidc/endpoints/forms.py:90
#: canaille/oidc/templates/token_view.html:67
msgid "Scope"
msgstr ""
#: canaille/oidc/endpoints/forms.py:95
#: canaille/oidc/endpoints/forms.py:96
msgid "Response types"
msgstr ""
#: canaille/oidc/endpoints/forms.py:101
#: canaille/oidc/endpoints/forms.py:102
msgid "Token Endpoint Auth Method"
msgstr ""
#: canaille/oidc/endpoints/forms.py:111
#: canaille/oidc/endpoints/forms.py:112
msgid "Token audiences"
msgstr ""
#: canaille/oidc/endpoints/forms.py:118
#: canaille/oidc/endpoints/forms.py:119
msgid "Logo URI"
msgstr ""
#: canaille/oidc/endpoints/forms.py:126
#: canaille/oidc/endpoints/forms.py:127
msgid "Terms of service URI"
msgstr ""
#: canaille/oidc/endpoints/forms.py:134
#: canaille/oidc/endpoints/forms.py:135
msgid "Policy URI"
msgstr ""
#: canaille/oidc/endpoints/forms.py:142
#: canaille/oidc/endpoints/forms.py:143
msgid "Software ID"
msgstr ""
#: canaille/oidc/endpoints/forms.py:147
#: canaille/oidc/endpoints/forms.py:148
msgid "Software Version"
msgstr ""
#: canaille/oidc/endpoints/forms.py:152
#: canaille/oidc/endpoints/forms.py:153
msgid "JWK"
msgstr ""
#: canaille/oidc/endpoints/forms.py:157
#: canaille/oidc/endpoints/forms.py:158
msgid "JKW URI"
msgstr ""
#: canaille/oidc/endpoints/forms.py:165
#: canaille/oidc/endpoints/forms.py:166
msgid "Pre-consent"
msgstr ""
#: canaille/oidc/endpoints/oauth.py:369
#: canaille/oidc/endpoints/oauth.py:377
msgid "You have been disconnected"
msgstr ""
#: canaille/oidc/endpoints/oauth.py:386
#: canaille/oidc/endpoints/oauth.py:394
msgid "You have not been disconnected"
msgstr ""
@ -1806,42 +1806,46 @@ msgstr ""
msgid "Subject"
msgstr ""
#: canaille/oidc/templates/token_view.html:71
#: canaille/oidc/templates/token_view.html:77
msgid "Audience"
msgstr ""
#: canaille/oidc/templates/token_view.html:85
#: canaille/oidc/templates/token_view.html:91
msgid "Issue date"
msgstr ""
#: canaille/oidc/templates/token_view.html:89
#: canaille/oidc/templates/token_view.html:95
msgid "Expiration date"
msgstr ""
#: canaille/oidc/templates/token_view.html:93
#: canaille/oidc/templates/token_view.html:99
msgid "Revokation date"
msgstr ""
#: canaille/oidc/templates/token_view.html:99
#: canaille/oidc/templates/token_view.html:105
msgid "Revoke token"
msgstr ""
#: canaille/oidc/templates/token_view.html:102
#: canaille/oidc/templates/token_view.html:108
msgid "This token has not been revoked"
msgstr ""
#: canaille/oidc/templates/token_view.html:108
#: canaille/oidc/templates/token_view.html:114
msgid "Token type"
msgstr ""
#: canaille/oidc/templates/token_view.html:112
#: canaille/oidc/templates/token_view.html:118
msgid "Access token"
msgstr ""
#: canaille/oidc/templates/token_view.html:123
#: canaille/oidc/templates/token_view.html:129
msgid "Refresh token"
msgstr ""
#: canaille/oidc/templates/token_view.html:131
msgid "No refresh token"
msgstr ""
#: canaille/oidc/templates/modals/delete-client.html:9
msgid "Client deletion"
msgstr ""

View file

@ -114,7 +114,11 @@ def populate(app):
post_logout_redirect_uris=["http://localhost:5001/logout_callback"],
tos_uri="http://localhost:5001/tos",
policy_uri="http://localhost:5001/policy",
grant_types=["authorization_code", "refresh_token"],
grant_types=[
"authorization_code",
"refresh_token",
"client_credentials",
],
scope=["openid", "profile", "email", "groups", "address", "phone"],
response_types=["code", "id_token"],
token_endpoint_auth_method="client_secret_basic",
@ -137,7 +141,11 @@ def populate(app):
],
tos_uri="http://localhost:5002/tos",
policy_uri="http://localhost:5002/policy",
grant_types=["authorization_code", "refresh_token"],
grant_types=[
"authorization_code",
"refresh_token",
"client_credentials",
],
scope=["openid", "profile", "email", "groups", "address", "phone"],
response_types=["code", "id_token"],
token_endpoint_auth_method="client_secret_basic",

View file

@ -58,6 +58,7 @@ def client(testclient, trusted_client, backend):
"implicit",
"hybrid",
"refresh_token",
"client_credentials",
],
response_types=["code", "token", "id_token"],
scope=["openid", "email", "profile", "groups", "address", "phone"],
@ -95,6 +96,7 @@ def trusted_client(testclient, backend):
"implicit",
"hybrid",
"refresh_token",
"client_credentials",
],
response_types=["code", "token", "id_token"],
scope=["openid", "profile", "groups"],

View file

@ -0,0 +1,28 @@
from canaille.app import models
from . import client_credentials
def test_nominal_case(testclient, client, keypair, trusted_client, backend, caplog):
res = testclient.post(
"/oauth/token",
params=dict(
grant_type="client_credentials",
scope="openid profile email groups address phone",
),
headers={"Authorization": f"Basic {client_credentials(client)}"},
status=200,
)
access_token = res.json["access_token"]
token = backend.get(models.Token, access_token=access_token)
assert token.client == client
assert token.subject is None
assert set(token.scope) == {
"openid",
"profile",
"email",
"groups",
"address",
"phone",
}

View file

@ -34,6 +34,7 @@ def test_get(testclient, backend, client, user):
"implicit",
"hybrid",
"refresh_token",
"client_credentials",
],
"response_types": ["code", "token", "id_token"],
"client_name": "Some client",