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.ENABLE_PASSWORD_COMPROMISSION_CHECK` and
|
||||||
:attr:`~canaille.core.configuration.CoreSettings.API_URL_HIBP`
|
:attr:`~canaille.core.configuration.CoreSettings.API_URL_HIBP`
|
||||||
:issue:`179`
|
:issue:`179`
|
||||||
|
- Implement OIDC client_credentials flow. :issue:`207`
|
||||||
|
|
||||||
Changed
|
Changed
|
||||||
^^^^^^^
|
^^^^^^^
|
||||||
|
|
|
@ -238,7 +238,7 @@ class Token(canaille.oidc.models.Token, Base, SqlAlchemyModel):
|
||||||
access_token: Mapped[str] = mapped_column(String, nullable=True)
|
access_token: Mapped[str] = mapped_column(String, nullable=True)
|
||||||
client_id: Mapped[str] = mapped_column(ForeignKey("client.id"))
|
client_id: Mapped[str] = mapped_column(ForeignKey("client.id"))
|
||||||
client: Mapped["Client"] = relationship()
|
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()
|
subject: Mapped["User"] = relationship()
|
||||||
type: Mapped[str] = mapped_column(String, nullable=True)
|
type: Mapped[str] = mapped_column(String, nullable=True)
|
||||||
refresh_token: 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"),
|
("implicit", "implicit"),
|
||||||
("hybrid", "hybrid"),
|
("hybrid", "hybrid"),
|
||||||
("refresh_token", "refresh_token"),
|
("refresh_token", "refresh_token"),
|
||||||
|
("client_credentials", "client_credentials"),
|
||||||
],
|
],
|
||||||
default=["authorization_code", "refresh_token"],
|
default=["authorization_code", "refresh_token"],
|
||||||
)
|
)
|
||||||
|
|
|
@ -192,7 +192,9 @@ def authorize_consent(client, user):
|
||||||
@csrf.exempt
|
@csrf.exempt
|
||||||
def issue_token():
|
def issue_token():
|
||||||
request_params = request.form.to_dict(flat=False)
|
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)
|
current_app.logger.debug("token endpoint request: POST: %s", request_params)
|
||||||
response = authorization.create_token_response()
|
response = authorization.create_token_response()
|
||||||
current_app.logger.debug("token endpoint response: %s", response.json)
|
current_app.logger.debug("token endpoint response: %s", response.json)
|
||||||
|
@ -201,9 +203,15 @@ def issue_token():
|
||||||
access_token = response.json["access_token"]
|
access_token = response.json["access_token"]
|
||||||
token = Backend.instance.get(models.Token, access_token=access_token)
|
token = Backend.instance.get(models.Token, access_token=access_token)
|
||||||
request_ip = request.remote_addr or "unknown IP"
|
request_ip = request.remote_addr or "unknown IP"
|
||||||
current_app.logger.security(
|
if token.subject:
|
||||||
f"Issued {grant_type} token for {token.subject.user_name} in client {token.client.client_name} from {request_ip}"
|
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
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -509,6 +509,11 @@ require_oauth = ResourceProtector()
|
||||||
|
|
||||||
|
|
||||||
def generate_access_token(client, grant_type, user, scope):
|
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]
|
audience = [client.client_id for client in client.audience]
|
||||||
bearer_token_generator = authorization._token_generators["default"]
|
bearer_token_generator = authorization._token_generators["default"]
|
||||||
kwargs = {
|
kwargs = {
|
||||||
|
|
|
@ -22,9 +22,15 @@
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="{{ url_for("core.account.profile_edition", edited_user=token.subject) }}">
|
{% if token.subject %}
|
||||||
{{ token.subject.user_name }}
|
<a href="{{ url_for("core.account.profile_edition", edited_user=token.subject) }}">
|
||||||
</a>
|
{{ token.subject.user_name }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('oidc.clients.edit', client=token.client) }}">
|
||||||
|
{{ token.client.client_name }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ token.issue_date }}</td>
|
<td>{{ token.issue_date }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
@ -52,9 +52,15 @@
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ _("Subject") }}</td>
|
<td>{{ _("Subject") }}</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="{{ url_for("core.account.profile_edition", edited_user=token.subject) }}">
|
{% if token.subject %}
|
||||||
{{ token.subject.identifier }}
|
<a href="{{ url_for("core.account.profile_edition", edited_user=token.subject) }}">
|
||||||
</a>
|
{{ token.subject.identifier }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for("oidc.clients.edit", client=token.client) }}">
|
||||||
|
{{ token.client.client_name }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -122,7 +128,7 @@
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ _("Refresh token") }}</td>
|
<td>{{ _("Refresh token") }}</td>
|
||||||
<td class="ui fluid action input">
|
<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">
|
<button class="ui primary right labeled icon button copy-button" data-copy="refresh-token">
|
||||||
<i class="copy icon"></i>
|
<i class="copy icon"></i>
|
||||||
{% trans %}Copy{% endtrans %}
|
{% trans %}Copy{% endtrans %}
|
||||||
|
|
|
@ -8,7 +8,7 @@ msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PROJECT VERSION\n"
|
"Project-Id-Version: PROJECT VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\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"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
|
@ -806,8 +806,8 @@ msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: canaille/core/templates/invite.html:74
|
#: canaille/core/templates/invite.html:74
|
||||||
#: canaille/oidc/templates/token_view.html:117
|
#: canaille/oidc/templates/token_view.html:123
|
||||||
#: canaille/oidc/templates/token_view.html:128
|
#: canaille/oidc/templates/token_view.html:134
|
||||||
msgid "Copy"
|
msgid "Copy"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -1468,7 +1468,7 @@ msgstr ""
|
||||||
#: canaille/core/templates/partial/users.html:73
|
#: canaille/core/templates/partial/users.html:73
|
||||||
#: canaille/oidc/templates/partial/authorization_list.html:31
|
#: canaille/oidc/templates/partial/authorization_list.html:31
|
||||||
#: canaille/oidc/templates/partial/client_list.html:35
|
#: 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"
|
msgid "No item matches your request"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -1477,7 +1477,7 @@ msgstr ""
|
||||||
#: canaille/core/templates/partial/users.html:75
|
#: canaille/core/templates/partial/users.html:75
|
||||||
#: canaille/oidc/templates/partial/authorization_list.html:33
|
#: canaille/oidc/templates/partial/authorization_list.html:33
|
||||||
#: canaille/oidc/templates/partial/client_list.html:37
|
#: 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?"
|
msgid "Maybe try with different criteria?"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -1486,7 +1486,7 @@ msgstr ""
|
||||||
#: canaille/core/templates/partial/users.html:78
|
#: canaille/core/templates/partial/users.html:78
|
||||||
#: canaille/oidc/templates/partial/authorization_list.html:36
|
#: canaille/oidc/templates/partial/authorization_list.html:36
|
||||||
#: canaille/oidc/templates/partial/client_list.html:40
|
#: 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
|
#: canaille/oidc/templates/preconsent_list.html:91
|
||||||
msgid "There is nothing here"
|
msgid "There is nothing here"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
@ -1581,60 +1581,60 @@ msgstr ""
|
||||||
msgid "Grant types"
|
msgid "Grant types"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: canaille/oidc/endpoints/forms.py:89
|
#: canaille/oidc/endpoints/forms.py:90
|
||||||
#: canaille/oidc/templates/token_view.html:61
|
#: canaille/oidc/templates/token_view.html:67
|
||||||
msgid "Scope"
|
msgid "Scope"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: canaille/oidc/endpoints/forms.py:95
|
#: canaille/oidc/endpoints/forms.py:96
|
||||||
msgid "Response types"
|
msgid "Response types"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: canaille/oidc/endpoints/forms.py:101
|
#: canaille/oidc/endpoints/forms.py:102
|
||||||
msgid "Token Endpoint Auth Method"
|
msgid "Token Endpoint Auth Method"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: canaille/oidc/endpoints/forms.py:111
|
#: canaille/oidc/endpoints/forms.py:112
|
||||||
msgid "Token audiences"
|
msgid "Token audiences"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: canaille/oidc/endpoints/forms.py:118
|
#: canaille/oidc/endpoints/forms.py:119
|
||||||
msgid "Logo URI"
|
msgid "Logo URI"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: canaille/oidc/endpoints/forms.py:126
|
#: canaille/oidc/endpoints/forms.py:127
|
||||||
msgid "Terms of service URI"
|
msgid "Terms of service URI"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: canaille/oidc/endpoints/forms.py:134
|
#: canaille/oidc/endpoints/forms.py:135
|
||||||
msgid "Policy URI"
|
msgid "Policy URI"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: canaille/oidc/endpoints/forms.py:142
|
#: canaille/oidc/endpoints/forms.py:143
|
||||||
msgid "Software ID"
|
msgid "Software ID"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: canaille/oidc/endpoints/forms.py:147
|
#: canaille/oidc/endpoints/forms.py:148
|
||||||
msgid "Software Version"
|
msgid "Software Version"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: canaille/oidc/endpoints/forms.py:152
|
#: canaille/oidc/endpoints/forms.py:153
|
||||||
msgid "JWK"
|
msgid "JWK"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: canaille/oidc/endpoints/forms.py:157
|
#: canaille/oidc/endpoints/forms.py:158
|
||||||
msgid "JKW URI"
|
msgid "JKW URI"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: canaille/oidc/endpoints/forms.py:165
|
#: canaille/oidc/endpoints/forms.py:166
|
||||||
msgid "Pre-consent"
|
msgid "Pre-consent"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: canaille/oidc/endpoints/oauth.py:369
|
#: canaille/oidc/endpoints/oauth.py:377
|
||||||
msgid "You have been disconnected"
|
msgid "You have been disconnected"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: canaille/oidc/endpoints/oauth.py:386
|
#: canaille/oidc/endpoints/oauth.py:394
|
||||||
msgid "You have not been disconnected"
|
msgid "You have not been disconnected"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -1806,42 +1806,46 @@ msgstr ""
|
||||||
msgid "Subject"
|
msgid "Subject"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: canaille/oidc/templates/token_view.html:71
|
#: canaille/oidc/templates/token_view.html:77
|
||||||
msgid "Audience"
|
msgid "Audience"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: canaille/oidc/templates/token_view.html:85
|
#: canaille/oidc/templates/token_view.html:91
|
||||||
msgid "Issue date"
|
msgid "Issue date"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: canaille/oidc/templates/token_view.html:89
|
#: canaille/oidc/templates/token_view.html:95
|
||||||
msgid "Expiration date"
|
msgid "Expiration date"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: canaille/oidc/templates/token_view.html:93
|
#: canaille/oidc/templates/token_view.html:99
|
||||||
msgid "Revokation date"
|
msgid "Revokation date"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: canaille/oidc/templates/token_view.html:99
|
#: canaille/oidc/templates/token_view.html:105
|
||||||
msgid "Revoke token"
|
msgid "Revoke token"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: canaille/oidc/templates/token_view.html:102
|
#: canaille/oidc/templates/token_view.html:108
|
||||||
msgid "This token has not been revoked"
|
msgid "This token has not been revoked"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: canaille/oidc/templates/token_view.html:108
|
#: canaille/oidc/templates/token_view.html:114
|
||||||
msgid "Token type"
|
msgid "Token type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: canaille/oidc/templates/token_view.html:112
|
#: canaille/oidc/templates/token_view.html:118
|
||||||
msgid "Access token"
|
msgid "Access token"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: canaille/oidc/templates/token_view.html:123
|
#: canaille/oidc/templates/token_view.html:129
|
||||||
msgid "Refresh token"
|
msgid "Refresh token"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: canaille/oidc/templates/token_view.html:131
|
||||||
|
msgid "No refresh token"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: canaille/oidc/templates/modals/delete-client.html:9
|
#: canaille/oidc/templates/modals/delete-client.html:9
|
||||||
msgid "Client deletion"
|
msgid "Client deletion"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
|
@ -114,7 +114,11 @@ def populate(app):
|
||||||
post_logout_redirect_uris=["http://localhost:5001/logout_callback"],
|
post_logout_redirect_uris=["http://localhost:5001/logout_callback"],
|
||||||
tos_uri="http://localhost:5001/tos",
|
tos_uri="http://localhost:5001/tos",
|
||||||
policy_uri="http://localhost:5001/policy",
|
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"],
|
scope=["openid", "profile", "email", "groups", "address", "phone"],
|
||||||
response_types=["code", "id_token"],
|
response_types=["code", "id_token"],
|
||||||
token_endpoint_auth_method="client_secret_basic",
|
token_endpoint_auth_method="client_secret_basic",
|
||||||
|
@ -137,7 +141,11 @@ def populate(app):
|
||||||
],
|
],
|
||||||
tos_uri="http://localhost:5002/tos",
|
tos_uri="http://localhost:5002/tos",
|
||||||
policy_uri="http://localhost:5002/policy",
|
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"],
|
scope=["openid", "profile", "email", "groups", "address", "phone"],
|
||||||
response_types=["code", "id_token"],
|
response_types=["code", "id_token"],
|
||||||
token_endpoint_auth_method="client_secret_basic",
|
token_endpoint_auth_method="client_secret_basic",
|
||||||
|
|
|
@ -58,6 +58,7 @@ def client(testclient, trusted_client, backend):
|
||||||
"implicit",
|
"implicit",
|
||||||
"hybrid",
|
"hybrid",
|
||||||
"refresh_token",
|
"refresh_token",
|
||||||
|
"client_credentials",
|
||||||
],
|
],
|
||||||
response_types=["code", "token", "id_token"],
|
response_types=["code", "token", "id_token"],
|
||||||
scope=["openid", "email", "profile", "groups", "address", "phone"],
|
scope=["openid", "email", "profile", "groups", "address", "phone"],
|
||||||
|
@ -95,6 +96,7 @@ def trusted_client(testclient, backend):
|
||||||
"implicit",
|
"implicit",
|
||||||
"hybrid",
|
"hybrid",
|
||||||
"refresh_token",
|
"refresh_token",
|
||||||
|
"client_credentials",
|
||||||
],
|
],
|
||||||
response_types=["code", "token", "id_token"],
|
response_types=["code", "token", "id_token"],
|
||||||
scope=["openid", "profile", "groups"],
|
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",
|
"implicit",
|
||||||
"hybrid",
|
"hybrid",
|
||||||
"refresh_token",
|
"refresh_token",
|
||||||
|
"client_credentials",
|
||||||
],
|
],
|
||||||
"response_types": ["code", "token", "id_token"],
|
"response_types": ["code", "token", "id_token"],
|
||||||
"client_name": "Some client",
|
"client_name": "Some client",
|
||||||
|
|
Loading…
Reference in a new issue