Skip to content

feat: Auto-propagate kid and alg from JWK to JWT header in RFC 7523 assertion signing#889

Open
liudonggalaxy wants to merge 2 commits into
authlib:mainfrom
liudonggalaxy:Dongliu/sign_jwt_bearer_assertion__set_jwt_header_parameter_from_key
Open

feat: Auto-propagate kid and alg from JWK to JWT header in RFC 7523 assertion signing#889
liudonggalaxy wants to merge 2 commits into
authlib:mainfrom
liudonggalaxy:Dongliu/sign_jwt_bearer_assertion__set_jwt_header_parameter_from_key

Conversation

@liudonggalaxy

@liudonggalaxy liudonggalaxy commented May 14, 2026

Copy link
Copy Markdown
Contributor

What kind of change does this PR introduce?

This is a feature implementation.

For the OAuth2 client credentials flow, when using private_key_jwt or client_secret_jwt authentication with a JWK that already contains kid and alg, the caller still has to extract those values from the key and pass them separately, even though the key itself is already passed to the function. This is cumbersome, especially in projects with many OAuth2 clients, where every call site has to repeat the same boilerplate. E.g.,

Before:

from joserfc.jwk import OKPKey, OctKey
 
 # Example 1: Ed25519 private key (private_key_jwt)
 ed25519_key = OKPKey.generate_key(crv="Ed25519")
 ed25519_key.update({"kid": "ed25519-key-1", "alg": "Ed25519"})
 
 # Manually extract kid and alg from the key we already pass
 token = private_key_jwt_sign(
     private_key=ed25519_key,
     client_id="my-client",
     token_endpoint="https://auth.example.com/token",
     alg=ed25519_key.get("alg"),               # redundant
     header={"kid": ed25519_key.get("kid")},   # redundant
 )
 
 # Example 2: HMAC shared secret (client_secret_jwt)
 hmac_key = OctKey.import_key(shared_secret)
 hmac_key.update({"kid": "hmac-key-1", "alg": "HS384"})
 
 # Same boilerplate for every call site in the project
 token = client_secret_jwt_sign(
     client_secret=hmac_key,
     client_id="another-client",
     token_endpoint="https://auth.example.com/token",
     alg=hmac_key.get("alg"),               # redundant
     header={"kid": hmac_key.get("kid")},   # redundant
 )

Solution: Automatically copy kid and alg from the JWK into the JWT header via a new helper set_jwt_header_parameter_from_key(...). The key's value is an enforced constraint (joserfc raises if header alg != key alg), so it takes priority over any explicit value.

Priority order:

  1. key.alg / key.kid — highest priority (enforced by joserfc)
  2. Explicit alg/kid parameter — used when key has no alg/kid
  3. Function default — HS256 for client_secret_jwt_sign, RS256 for private_key_jwt_sign

After:

from joserfc.jwk import OKPKey, OctKey
 
 # Example 1: Ed25519 private key
 ed25519_key = OKPKey.generate_key(crv="Ed25519")
 ed25519_key.update({"kid": "ed25519-key-1", "alg": "Ed25519"})
 
 # Just pass the key — kid and alg are read from it automatically
 token = private_key_jwt_sign(
     private_key=ed25519_key,
     client_id="my-client",
     token_endpoint="https://auth.example.com/token",
 )
 
 # Example 2: HMAC shared secret
 hmac_key = OctKey.import_key(shared_secret)
 hmac_key.update({"kid": "hmac-key-1", "alg": "HS384"})
 
 # Same simplification — no manual extraction needed
 token = client_secret_jwt_sign(
     client_secret=hmac_key,
     client_id="another-client",
     token_endpoint="https://auth.example.com/token",
 )

Fully backward compatible:

  • Keys without alg/kid → behavior unchanged, explicit params or defaults apply
  • Keys with alg/kid → they were already required to match (joserfc enforces this), so auto-propagation just removes the redundant boilerplate

Checklist

  • The commits follow the conventional commits specification.
  • You ran the linters with prek.
  • You wrote unit test to demonstrate the bug you are fixing, or to stress the feature you are bringing.
  • You reached 100% of code coverage on the code you edited, without abusive use of pragma: no cover
  • If this PR is about a new feature, or a behavior change, you have updated the documentation accordingly.

  • You consent that the copyright of your pull request source code belongs to Authlib's author.

@liudonggalaxy liudonggalaxy force-pushed the Dongliu/sign_jwt_bearer_assertion__set_jwt_header_parameter_from_key branch from ca4f8aa to 79d0d80 Compare May 14, 2026 23:59
@liudonggalaxy liudonggalaxy marked this pull request as ready for review May 15, 2026 00:07
@liudonggalaxy liudonggalaxy changed the title Auto-propagate kid and alg from JWK to JWT header in RFC 7523 assertion signing feat: Auto-propagate kid and alg from JWK to JWT header in RFC 7523 assertion signing May 15, 2026
Comment thread authlib/oauth2/rfc7523/assertion.py Outdated
from typing import Any

from joserfc import jwt
from joserfc._rfc7517.models import BaseKey

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better not import from a private module. You can use

from joserfc.jwk import OctKey, RSAKey, ECKey, OKPKey

if isinstance(key,(OctKey, RSAKey, ECKey, OKPKey)):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

if isinstance(key, (OctKey, RSAKey, ECKey, OKPKey)):
parameter_value = key.get(parameter_name)
if parameter_value:
header[parameter_name] = parameter_value

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you should use header.setdefault, if the given header contains alg field, you should not use the alg value from the key.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if the given header contains alg field, you should not use the alg value from the key.

  1. client_secret_jwt_sign(...) uses HS256 as the default algorithm. private_key_jwt_sign(...) uses RS256 as the default algorithm. These defaults override key.alg. As a result, the following usage is no longer supported. We have to manually provide the alg parameter to the function.
token = private_key_jwt_sign(
    private_key=ed25519_key,
    client_id="my-client",
    token_endpoint="https://auth.example.com/token",
)

Likewise, the following pattern is no longer valid:

with OAuth2Session(
    client_id,
    client_secret,
    token_endpoint_auth_method=ClientSecretJWT(token_endpoint),
    token_endpoint=token_endpoint,
    grant_type="client_credentials",
) as session:

Instead, the algorithm and headers must be specified explicitly:

with OAuth2Session(
    client_id,
    client_secret,
    token_endpoint_auth_method=ClientSecretJWT(
        token_endpoint,
        alg=client_secret["alg"],
        headers={"kid": client_secret.get("kid")},
    ),
    token_endpoint=token_endpoint,
    grant_type="client_credentials",
) as session:
  1. There are three possible sources for the algorithm (alg):
  • header.alg
  • The function parameter alg
  • key.alg

If either header.alg or the function parameter alg does not match key.alg, joserfc raises an exception and the function call fails.

In practice, the only way for users to avoid this failure is to ensure that header.alg matches key.alg. Therefore, key.alg effectively acts as a constraint on all other algorithm values and has the final authority over algorithm selection.

As a result, key.alg has the highest priority because it can reject alg values provided from any other source.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants