diff --git a/CHANGES.rst b/CHANGES.rst
index 6a6767da..bde662e6 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -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
^^^^^^^
diff --git a/canaille/backends/sql/models.py b/canaille/backends/sql/models.py
index 754b2113..fc09a021 100644
--- a/canaille/backends/sql/models.py
+++ b/canaille/backends/sql/models.py
@@ -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)
diff --git a/canaille/oidc/endpoints/forms.py b/canaille/oidc/endpoints/forms.py
index 1d78fa9c..ccba4e7f 100644
--- a/canaille/oidc/endpoints/forms.py
+++ b/canaille/oidc/endpoints/forms.py
@@ -82,6 +82,7 @@ class ClientAddForm(Form):
("implicit", "implicit"),
("hybrid", "hybrid"),
("refresh_token", "refresh_token"),
+ ("client_credentials", "client_credentials"),
],
default=["authorization_code", "refresh_token"],
)
diff --git a/canaille/oidc/endpoints/oauth.py b/canaille/oidc/endpoints/oauth.py
index 2c0fb2a4..5f3529a7 100644
--- a/canaille/oidc/endpoints/oauth.py
+++ b/canaille/oidc/endpoints/oauth.py
@@ -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
diff --git a/canaille/oidc/oauth.py b/canaille/oidc/oauth.py
index 18328375..c7f1a424 100644
--- a/canaille/oidc/oauth.py
+++ b/canaille/oidc/oauth.py
@@ -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 = {
diff --git a/canaille/oidc/templates/partial/token_list.html b/canaille/oidc/templates/partial/token_list.html
index 7228786c..8e7d5178 100644
--- a/canaille/oidc/templates/partial/token_list.html
+++ b/canaille/oidc/templates/partial/token_list.html
@@ -22,9 +22,15 @@
{{ _("Refresh token") }} |
-
+
|