forked from Github-Mirrors/canaille
feat: implement OIDC client_credentials flow
This commit is contained in:
parent
a4985184fa
commit
5bc438d21d
12 changed files with 114 additions and 44 deletions
|
@ -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
|
||||
^^^^^^^
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -82,6 +82,7 @@ class ClientAddForm(Form):
|
|||
("implicit", "implicit"),
|
||||
("hybrid", "hybrid"),
|
||||
("refresh_token", "refresh_token"),
|
||||
("client_credentials", "client_credentials"),
|
||||
],
|
||||
default=["authorization_code", "refresh_token"],
|
||||
)
|
||||
|
|
|
@ -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"
|
||||
current_app.logger.security(
|
||||
f"Issued {grant_type} token for {token.subject.user_name} in client {token.client.client_name} from {request_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
|
||||
|
||||
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -22,9 +22,15 @@
|
|||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for("core.account.profile_edition", edited_user=token.subject) }}">
|
||||
{{ token.subject.user_name }}
|
||||
</a>
|
||||
{% 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>
|
||||
|
|
|
@ -52,9 +52,15 @@
|
|||
<tr>
|
||||
<td>{{ _("Subject") }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for("core.account.profile_edition", edited_user=token.subject) }}">
|
||||
{{ token.subject.identifier }}
|
||||
</a>
|
||||
{% 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 %}
|
||||
|
|
|
@ -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 ""
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"],
|
||||
|
|
28
tests/oidc/test_client_credentials_flow.py
Normal file
28
tests/oidc/test_client_credentials_flow.py
Normal 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",
|
||||
}
|
|
@ -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",
|
||||
|
|
Loading…
Reference in a new issue