Repository: authlib/authlib

Issue:
Use the client metadata `id_token_signed_response_alg` to sign the `id_token`
The OIDC Dynamic Client Registration spec details the `id_token_signed_response_alg` parameter. The alg value of this parameter should be used to sign the `id_token`.
This is needed to pass the `oidcc-idtoken-unsigned` test from the certification `basic` plan.

[oidc-core §3.1.3.7](https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation)

> The alg value SHOULD be the default of RS256 or the algorithm sent by the Client in the id_token_signed_response_alg parameter during Registration. 

[oidc-registration §2](https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata)
> id_token_signed_response_alg
    OPTIONAL. JWS alg algorithm [[JWA]](https://openid.net/specs/openid-connect-registration-1_0.html#JWA) REQUIRED for signing the ID Token issued to this Client. The value none MUST NOT be used as the ID Token alg value unless the Client uses only Response Types that return no ID Token from the Authorization Endpoint (such as when only using the Authorization Code Flow). The default, if omitted, is RS256. The public key for validating the signature is provided by retrieving the JWK Set referenced by the jwks_uri element from [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-registration-1_0.html#OpenID.Discovery) [OpenID.Discovery].

[oidc-discovery §3](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata)

> id_token_signing_alg_values_supported
    REQUIRED. JSON array containing a list of the JWS signing algorithms (alg values) supported by the OP for the ID Token to encode the Claims in a JWT [[JWT]](https://openid.net/specs/openid-connect-discovery-1_0.html#JWT). The algorithm RS256 MUST be included. The value none MAY be supported but MUST NOT be used unless the Response Type used returns no ID Token from the Authorization Endpoint (such as when using the Authorization Code Flow).


Discussion:

Patch:
diff --git a/authlib/integrations/sqla_oauth2/client_mixin.py b/authlib/integrations/sqla_oauth2/client_mixin.py
index 2bba8a57..c8835086 100644
--- a/authlib/integrations/sqla_oauth2/client_mixin.py
+++ b/authlib/integrations/sqla_oauth2/client_mixin.py
@@ -110,6 +110,10 @@ def software_id(self):
     def software_version(self):
         return self.client_metadata.get("software_version")
 
+    @property
+    def id_token_signed_response_alg(self):
+        return self.client_metadata.get("id_token_signed_response_alg")
+
     def get_client_id(self):
         return self.client_id
 
diff --git a/authlib/oidc/core/grants/code.py b/authlib/oidc/core/grants/code.py
index e34d19d2..767781fa 100644
--- a/authlib/oidc/core/grants/code.py
+++ b/authlib/oidc/core/grants/code.py
@@ -22,8 +22,12 @@
 class OpenIDToken:
     def get_jwt_config(self, grant):  # pragma: no cover
         """Get the JWT configuration for OpenIDCode extension. The JWT
-        configuration will be used to generate ``id_token``. Developers
-        MUST implement this method in subclass, e.g.::
+        configuration will be used to generate ``id_token``.
+        If ``alg`` is undefined, the ``id_token_signed_response_alg`` client
+        metadata will be used. By default ``RS256`` will be used.
+        If ``key`` is undefined, the ``jwks_uri`` or ``jwks`` client metadata
+        will be used.
+        Developers MUST implement this method in subclass, e.g.::
 
             def get_jwt_config(self, grant):
                 return {
@@ -77,6 +81,13 @@ def process_token(self, grant, response):
         config = self.get_jwt_config(grant)
         config["aud"] = self.get_audiences(request)
 
+        # Per OpenID Connect Registration 1.0 Section 2:
+        # Use client's id_token_signed_response_alg if specified
+        if not config.get("alg") and (
+            client_alg := request.client.id_token_signed_response_alg
+        ):
+            config["alg"] = client_alg
+
         if authorization_code:
             config["nonce"] = authorization_code.get_nonce()
             config["auth_time"] = authorization_code.get_auth_time()
diff --git a/authlib/oidc/core/grants/implicit.py b/authlib/oidc/core/grants/implicit.py
index 398367da..4aafdede 100644
--- a/authlib/oidc/core/grants/implicit.py
+++ b/authlib/oidc/core/grants/implicit.py
@@ -4,6 +4,7 @@
 from authlib.oauth2.rfc6749 import ImplicitGrant
 from authlib.oauth2.rfc6749 import InvalidScopeError
 from authlib.oauth2.rfc6749 import OAuth2Error
+from authlib.oauth2.rfc6749.errors import InvalidRequestError
 from authlib.oauth2.rfc6749.hooks import hooked
 
 from .util import create_response_mode_response
@@ -148,6 +149,26 @@ def process_implicit_token(self, token, code=None):
         if code is not None:
             config["code"] = code
 
+        # Per OpenID Connect Registration 1.0 Section 2:
+        # Use client's id_token_signed_response_alg if specified
+        if not config.get("alg") and (
+            client_alg := self.request.client.id_token_signed_response_alg
+        ):
+            if client_alg == "none":
+                # According to oidc-registration §2 the 'none' alg is not valid in
+                # implicit flows:
+                #    The value none MUST NOT be used as the ID Token alg value unless
+                #    the Client uses only Response Types that return no ID Token from
+                #    the Authorization Endpoint (such as when only using the
+                #    Authorization Code Flow).
+                raise InvalidRequestError(
+                    "id_token must be signed in implicit flows",
+                    redirect_uri=self.request.payload.redirect_uri,
+                    redirect_fragment=True,
+                )
+
+            config["alg"] = client_alg
+
         user_info = self.generate_user_info(self.request.user, token["scope"])
         id_token = generate_id_token(token, user_info, **config)
         token["id_token"] = id_token
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 7f24030f..a9c49e64 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -6,6 +6,14 @@ Changelog
 
 Here you can see the full list of changes between each Authlib release.
 
+Version 1.6.3
+-------------
+
+**Unreleased**
+
+- OIDC ``id_token`` are signed according to ``id_token_signed_response_alg``
+  client metadata. :issue:`755`
+
 Version 1.6.2
 -------------
 


Test patch:
diff --git a/tests/flask/test_oauth2/test_openid_code_grant.py b/tests/flask/test_oauth2/test_openid_code_grant.py
index 04715b0b..564a6788 100644
--- a/tests/flask/test_oauth2/test_openid_code_grant.py
+++ b/tests/flask/test_oauth2/test_openid_code_grant.py
@@ -31,9 +31,9 @@ def save_authorization_code(self, code, request):
 
 class OpenIDCode(_OpenIDCode):
     def get_jwt_config(self, grant):
-        key = current_app.config["OAUTH2_JWT_KEY"]
-        alg = current_app.config["OAUTH2_JWT_ALG"]
-        iss = current_app.config["OAUTH2_JWT_ISS"]
+        key = current_app.config.get("OAUTH2_JWT_KEY")
+        alg = current_app.config.get("OAUTH2_JWT_ALG")
+        iss = current_app.config.get("OAUTH2_JWT_ISS")
         return dict(key=key, alg=alg, iss=iss, exp=3600)
 
     def exists_nonce(self, nonce, request):
@@ -53,7 +53,7 @@ def config_app(self):
             }
         )
 
-    def prepare_data(self, require_nonce=False):
+    def prepare_data(self, require_nonce=False, id_token_signed_response_alg=None):
         self.config_app()
         server = create_authorization_server(self.app)
         server.register_grant(
@@ -75,6 +75,7 @@ def prepare_data(self, require_nonce=False):
                 "scope": "openid profile address",
                 "response_types": ["code"],
                 "grant_types": ["authorization_code"],
+                "id_token_signed_response_alg": id_token_signed_response_alg,
             }
         )
         db.session.add(client)
@@ -238,6 +239,82 @@ def test_prompt_none_not_logged(self):
         assert params["error"] == "login_required"
         assert params["state"] == "bar"
 
+    def test_client_metadata_custom_alg(self):
+        """If the client metadata 'id_token_signed_response_alg' is defined,
+        it should be used to sign id_tokens."""
+        self.prepare_data(id_token_signed_response_alg="HS384")
+        del self.app.config["OAUTH2_JWT_ALG"]
+        rv = self.client.post(
+            "/oauth/authorize",
+            data={
+                "response_type": "code",
+                "client_id": "code-client",
+                "state": "bar",
+                "scope": "openid profile",
+                "redirect_uri": "https://a.b",
+                "user_id": "1",
+            },
+        )
+        params = dict(url_decode(urlparse.urlparse(rv.location).query))
+        code = params["code"]
+        headers = self.create_basic_header("code-client", "code-secret")
+        rv = self.client.post(
+            "/oauth/token",
+            data={
+                "grant_type": "authorization_code",
+                "redirect_uri": "https://a.b",
+                "code": code,
+            },
+            headers=headers,
+        )
+        resp = json.loads(rv.data)
+        claims = jwt.decode(
+            resp["id_token"],
+            "secret",
+            claims_cls=CodeIDToken,
+            claims_options={"iss": {"value": "Authlib"}},
+        )
+        claims.validate()
+        assert claims.header["alg"] == "HS384"
+
+    def test_client_metadata_alg_none(self):
+        """The 'none' 'id_token_signed_response_alg' alg should be
+        supported in non implicit flows."""
+        self.prepare_data(id_token_signed_response_alg="none")
+        del self.app.config["OAUTH2_JWT_ALG"]
+        rv = self.client.post(
+            "/oauth/authorize",
+            data={
+                "response_type": "code",
+                "client_id": "code-client",
+                "state": "bar",
+                "scope": "openid profile",
+                "redirect_uri": "https://a.b",
+                "user_id": "1",
+            },
+        )
+        params = dict(url_decode(urlparse.urlparse(rv.location).query))
+        code = params["code"]
+        headers = self.create_basic_header("code-client", "code-secret")
+        rv = self.client.post(
+            "/oauth/token",
+            data={
+                "grant_type": "authorization_code",
+                "redirect_uri": "https://a.b",
+                "code": code,
+            },
+            headers=headers,
+        )
+        resp = json.loads(rv.data)
+        claims = jwt.decode(
+            resp["id_token"],
+            "secret",
+            claims_cls=CodeIDToken,
+            claims_options={"iss": {"value": "Authlib"}},
+        )
+        claims.validate()
+        assert claims.header["alg"] == "none"
+
 
 class RSAOpenIDCodeTest(BaseTestCase):
     def config_app(self):
diff --git a/tests/flask/test_oauth2/test_openid_implict_grant.py b/tests/flask/test_oauth2/test_openid_implict_grant.py
index e7b4cdaa..45b911af 100644
--- a/tests/flask/test_oauth2/test_openid_implict_grant.py
+++ b/tests/flask/test_oauth2/test_openid_implict_grant.py
@@ -1,3 +1,5 @@
+from flask import current_app
+
 from authlib.common.urls import add_params_to_uri
 from authlib.common.urls import url_decode
 from authlib.common.urls import urlparse
@@ -15,7 +17,8 @@
 
 class OpenIDImplicitGrant(_OpenIDImplicitGrant):
     def get_jwt_config(self):
-        return dict(key="secret", alg="HS256", iss="Authlib", exp=3600)
+        alg = current_app.config.get("OAUTH2_JWT_ALG", "HS256")
+        return dict(key="secret", alg=alg, iss="Authlib", exp=3600)
 
     def generate_user_info(self, user, scopes):
         return user.generate_user_info(scopes)
@@ -25,7 +28,7 @@ def exists_nonce(self, nonce, request):
 
 
 class ImplicitTest(TestCase):
-    def prepare_data(self):
+    def prepare_data(self, id_token_signed_response_alg=None):
         server = create_authorization_server(self.app)
         server.register_grant(OpenIDImplicitGrant)
 
@@ -43,6 +46,7 @@ def prepare_data(self):
                 "scope": "openid profile",
                 "token_endpoint_auth_method": "none",
                 "response_types": ["id_token", "id_token token"],
+                "id_token_signed_response_alg": id_token_signed_response_alg,
             }
         )
         self.authorize_url = (
@@ -51,12 +55,13 @@ def prepare_data(self):
         db.session.add(client)
         db.session.commit()
 
-    def validate_claims(self, id_token, params):
-        jwt = JsonWebToken(["HS256"])
+    def validate_claims(self, id_token, params, alg="HS256"):
+        jwt = JsonWebToken([alg])
         claims = jwt.decode(
             id_token, "secret", claims_cls=ImplicitIDToken, claims_params=params
         )
         claims.validate()
+        return claims
 
     def test_consent_view(self):
         self.prepare_data()
@@ -199,3 +204,44 @@ def test_response_mode_form_post(self):
         )
         assert b'name="id_token"' in rv.data
         assert b'name="state"' in rv.data
+
+    def test_client_metadata_custom_alg(self):
+        """If the client metadata 'id_token_signed_response_alg' is defined,
+        it should be used to sign id_tokens."""
+        self.prepare_data(id_token_signed_response_alg="HS384")
+        self.app.config["OAUTH2_JWT_ALG"] = None
+        rv = self.client.post(
+            "/oauth/authorize",
+            data={
+                "response_type": "id_token",
+                "client_id": "implicit-client",
+                "scope": "openid profile",
+                "state": "foo",
+                "redirect_uri": "https://a.b/c",
+                "user_id": "1",
+                "nonce": "abc",
+            },
+        )
+        params = dict(url_decode(urlparse.urlparse(rv.location).fragment))
+        claims = self.validate_claims(params["id_token"], params, "HS384")
+        assert claims.header["alg"] == "HS384"
+
+    def test_client_metadata_alg_none(self):
+        """The 'none' 'id_token_signed_response_alg' alg should be
+        forbidden in non implicit flows."""
+        self.prepare_data(id_token_signed_response_alg="none")
+        self.app.config["OAUTH2_JWT_ALG"] = None
+        rv = self.client.post(
+            "/oauth/authorize",
+            data={
+                "response_type": "id_token",
+                "client_id": "implicit-client",
+                "scope": "openid profile",
+                "state": "foo",
+                "redirect_uri": "https://a.b/c",
+                "user_id": "1",
+                "nonce": "abc",
+            },
+        )
+        params = dict(url_decode(urlparse.urlparse(rv.location).fragment))
+        assert params["error"] == "invalid_request"