lepture / authlib

The ultimate Python library in building OAuth, OpenID Connect clients and servers. JWS,JWE,JWK,JWA,JWT included.
https://authlib.org/
BSD 3-Clause "New" or "Revised" License
4.52k stars 452 forks source link

Why self.form.get('grant_type') and not self.data.get('grant_type') ? #658

Closed lguichard78 closed 2 months ago

lguichard78 commented 3 months ago

https://github.com/lepture/authlib/blob/0ad753cbe39e3cb5bee33ef93b7497020a33dea1/authlib/oauth2/rfc6749/requests.py#L79

codespearhead commented 3 months ago

Section-4.3.2 of RFC 6749 says grant_type should go in the request body:

"The client makes a request to the token endpoint by adding the following parameters using the "application/x-www-form-urlencoded" format per Appendix B with a character encoding of UTF-8 in the HTTP request entity-body".

So that property should be retrieved from the request body, not from the request URI, and since the data property aggregates data from both the query parameters (self.args) and and the request body (self.form), self.form.get('grant_type') avoids the risk of getting it from the wrong place, especially if that property is not defined in the request body.

https://github.com/lepture/authlib/blob/0ad753cbe39e3cb5bee33ef93b7497020a33dea1/authlib/oauth2/rfc6749/requests.py#L34-L36 https://github.com/lepture/authlib/blob/0ad753cbe39e3cb5bee33ef93b7497020a33dea1/authlib/oauth2/rfc6749/requests.py#L38-L43

apvd commented 3 months ago

Hi @codespearhead, thanks for checking out this PR! I believe that Section 4.3.2 applies specifically to Resource Owner Password Credentials Grants. That section does say the grant_type may be in the form body, but it also says: grant_type REQUIRED. Value MUST be set to "password". Clearly that only applies to password type grants. However, even for password grants, section 4.3.2 only shows the grant_type may be provided via form body by way of example. It does not say grant_type MUST be provided by form body, nor does it explicitly prohibit URL parameter grant_type implementations.

The OAuth2Request class is intended to support grant types other than just password though. Using data would still be compatible with password grants as defined in RFC6749 by accepting the grant_type field through the form body, but would also allow other grant types and situations. Since grant_type is treated similarly with other parameters like scope, response_type and redirect_uri in the RFC, it would make sense to treat grant_type the same as those other parameters in OAuth2Request as well.

lepture commented 2 months ago

@apvd Actually, no matter which grant type, it should be in the request body.

https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3 https://datatracker.ietf.org/doc/html/rfc6749#section-4.3.2 https://datatracker.ietf.org/doc/html/rfc6749#section-4.4.2

apvd commented 2 months ago

Thanks for following up! I see that the types currently defined in rfc6749 are in the form body. But authlib is meant to be extensible to new grant types, correct? I'd like to implement one where the grant type is in the URL.

What's the recommended way to support TOKEN_ENDPOINT_HTTP_METHODS = ["GET"]? Thanks!

-Aaron

On Wed, Jul 10, 2024 at 8:29 AM Hsiaoming Yang @.***> wrote:

@apvd https://github.com/apvd Actually, no matter which grant type, it should be in the request body.

https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3 https://datatracker.ietf.org/doc/html/rfc6749#section-4.3.2 https://datatracker.ietf.org/doc/html/rfc6749#section-4.4.2

— Reply to this email directly, view it on GitHub https://github.com/lepture/authlib/issues/658#issuecomment-2220826777, or unsubscribe https://github.com/notifications/unsubscribe-auth/ABGO6OZP66ITXV6NNHHOXVDZLVHNDAVCNFSM6AAAAABKJZHCIWVHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZDEMRQHAZDMNZXG4 . You are receiving this because you were mentioned.Message ID: @.***>

codespearhead commented 2 months ago

I wonder what use case justifies creating a new grant type, because if that grant type is not defined in the OAuth2 specification, whatever you create will no longer be OAuth2-compliant: it will become a custom authorization protocol used only by you, which is the problem the OAuth2 specification was created to solve in the first place.

It's safe to assume there's no risk of there ever being an OAuth2 grant type that declares that field as a URL parameter. This is because not having a single source of truth is widely regarded as a bad practice: it leaves room for undefined behavior that inevitably becomes the root cause of interoperability issues due to multiple interpretations of the spec across implementations, which is one of the major reasons why the SOAP protocol fell out of favor.

However, if you're creating your own specification based on OAuth2 for learning purposes, I suggest explicitly defining in your custom specification what should happen when that attribute is declared in the wrong place, as well as what should happen when it's declared in both the right and wrong places. Then, you can implement that logic in the following files in your own fork of this library that conforms to your custom specification, which to my knowledge, currently isn't a compliance target of this library:

https://github.com/lepture/authlib/blob/0ad753cbe39e3cb5bee33ef93b7497020a33dea1/authlib%2Foauth2%2Frfc6749%2Frequests.py#L38-L43

https://github.com/lepture/authlib/blob/0ad753cbe39e3cb5bee33ef93b7497020a33dea1/authlib%2Foauth2%2Frfc6749%2Frequests.py#L34-L36

apvd commented 2 months ago

Thanks, Pedro!

The RFC also allows for extended grant types that are up to server implementation. https://datatracker.ietf.org/doc/html/rfc6749#section-4.5

So it's possible to have a grant that gets a token from a GET request. The scenario I am thinking is flows that want on OIDC token (response_type=id_token) often want it from a GET, so it would be necessary to extend it with a grant that supports that, ergo gets the grant_type from the URL params.

I am curious how the "single source of truth" applies to scope, response_type and redirect_uri, which all can obtained from data?

On Wed, Jul 10, 2024 at 11:31 AM Pedro Aguiar @.***> wrote:

I wonder what use case justifies creating a new grant type, because if that grant type is not defined in the OAuth2 specification, whatever you create will no longer be OAuth2-compliant: it will become a custom authorization protocol used only by you, which is the problem the OAuth2 specification was created to solve in the first place.

It's safe to assume there's no risk of there ever being an OAuth2 grant type that declares that field as a URL parameter. This is because not having a single source of truth is widely regarded as a bad practice: it leaves room for undefined behavior and thus increases maintenance burden because of interoperability issues, which is one of the major reasons why the SOAP protocol fell out of favor.

However, if you're creating your own specification based on OAuth2 for learning purposes, I suggest explicitly defining in your custom specification what should happen when that attribute is declared in the wrong place, as well as what should happen when it's declared in both the right and wrong places. Then, you can implement that logic in the following files in your own fork of this library that conforms to your custom specification:

https://github.com/lepture/authlib/blob/0ad753cbe39e3cb5bee33ef93b7497020a33dea1/authlib%2Foauth2%2Frfc6749%2Frequests.py#L38-L43

https://github.com/lepture/authlib/blob/0ad753cbe39e3cb5bee33ef93b7497020a33dea1/authlib%2Foauth2%2Frfc6749%2Frequests.py#L34-L36

— Reply to this email directly, view it on GitHub https://github.com/lepture/authlib/issues/658#issuecomment-2221178221, or unsubscribe https://github.com/notifications/unsubscribe-auth/ABGO6O5EVO2LYXK4EHCVBKLZLV4XRAVCNFSM6AAAAABKJZHCIWVHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZDEMRRGE3TQMRSGE . You are receiving this because you were mentioned.Message ID: @.***>

codespearhead commented 2 months ago

As per Section 3.2 (Token Endpoint) of RFC6749, that endpoint can only be accessed by the HTTP "POST" method:

The client MUST use the HTTP "POST" method when making access token requests.

So extended grant types cannot use the HTTP "GET" method.

That's to ensure secure transmission of sensitive parameters, since URIs can be logged (intermediary proxies, referer headers, etc.) or stored in browser history.

Either way, I believe we're dealing with an XY problem here, so I wonder what problem you're facing that isn't addressed by the defined OAuth2 flows, to which the suggested solution attempt of defining a custom flow where obtaining the access token from a GET request seems like an appealing solution.

apvd commented 2 months ago

I'd like to support a URL sourced OIDC token. Google requires those id tokens to come from a GET url. If GET is prohibited, maybe it just means that authlib/Oauth2 cannot service as a provider of OIDC tokens for this purpose in a direct way. https://cloud.google.com/iam/docs/workforce-obtaining-short-lived-credentials#oidc-url

I'll close my PR since it's been fully considered and rejected. Thanks for your attention! -Aaron

On Wed, Jul 10, 2024 at 5:04 PM Pedro Aguiar @.***> wrote:

As per Section 3.2 (Token Endpoint) of RFC6749 https://datatracker.ietf.org/doc/html/rfc6749#section-3.2, that endpoint can only be accessed by the HTTP "POST" method:

The client MUST use the HTTP "POST" method when making access token requests.

So extended grant types cannot use the HTTP "GET" method.

That's to ensure secure transmission of sensitive parameters, since URIs can be logged (intermediary proxies, referer headers, etc.) or stored in browser history.

Either way, I believe we're dealing with an XY problem https://xyproblem.info/ here, so I wonder what problem you're facing that isn't addressed by the defined OAuth2 flows, to which the suggested solution attempt of defining a custom flow where obtaining the access token from a GET request seems like an appealing solution.

— Reply to this email directly, view it on GitHub https://github.com/lepture/authlib/issues/658#issuecomment-2221739474, or unsubscribe https://github.com/notifications/unsubscribe-auth/ABGO6O3R5MYJ3LAMG6QRRETZLXD2XAVCNFSM6AAAAABKJZHCIWVHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZDEMRRG4ZTSNBXGQ . You are receiving this because you were mentioned.Message ID: @.***>

codespearhead commented 2 months ago

This "URL-sourced Credential Flow" is indeed a custom OIDC flow by Google, which was likely created to keep legacy clients running, so at first glance none of the standard flows will suffice. However, implementing it yourself shouldn't be that hard given that you can use authlib's primitives (implementation provided at the end of this answer).

However, it's worth noting that such a flow is for creating Google credentials manually, which is something next section on the same page suggests avoiding:

If you use a supported client library, you can configure the client library so that it generates Google credentials automatically. When possible, we recommend that you generate credentials automatically, so that you don't need to implement the token-exchange process yourself.

Since you created this issue in a Python package, I assume you're using Python, and Google offers an official client library whose usage is exemplified in that very section so you can cut to the chase:

from google.cloud import storage
import google.auth

credentials, project = google.auth.default(
    scopes=['https://www.googleapis.com/auth/devstorage.read_only'])

client = storage.Client(
    project="project-id", credentials=credentials)

Does that solve your problem?


ChatGPT-generated answer, which although I haven't tested myself, it looks like it should work.

Complete Code

import requests
from authlib.integrations.requests_client import OAuth2Session

class GoogleCloudAuthenticator:
    def __init__(self, workforce_pool_id, provider_id, url_to_return_oidc_id_token, workforce_pool_user_project):
        self.workforce_pool_id = workforce_pool_id
        self.provider_id = provider_id
        self.url_to_return_oidc_id_token = url_to_return_oidc_id_token
        self.workforce_pool_user_project = workforce_pool_user_project
        self.audience = f"//iam.googleapis.com/locations/global/workforcePools/{self.workforce_pool_id}/providers/{self.provider_id}"
        self.token_url = "https://sts.googleapis.com/v1/token"
        self.subject_token_type = "urn:ietf:params:oauth:token-type:id_token"

    def fetch_oidc_id_token(self):
        """
        Fetch the OIDC ID token from the specified URL.

        Returns:
        str: The OIDC ID token.
        """
        response = requests.get(self.url_to_return_oidc_id_token)
        response.raise_for_status()
        return response.json().get('id_token')

    def exchange_token_for_google_credentials(self, token):
        """
        Exchange the OIDC ID token for Google Cloud credentials using Google's STS.

        Args:
        token (str): The OIDC ID token.

        Returns:
        dict: The response containing Google Cloud credentials.
        """
        client = OAuth2Session()
        data = {
            'grant_type': 'urn:ietf:params:oauth:grant-type:token-exchange',
            'audience': self.audience,
            'subject_token': token,
            'subject_token_type': self.subject_token_type,
            'requested_token_type': 'urn:ietf:params:oauth:token-type:access_token',
            'scope': 'https://www.googleapis.com/auth/cloud-platform',
            'options': {
                'userProject': self.workforce_pool_user_project
            }
        }
        response = client.post(self.token_url, data=data)
        response.raise_for_status()
        return response.json()

    def authenticate(self):
        """
        Authenticate by fetching the OIDC ID token and exchanging it for Google Cloud credentials.

        Returns:
        dict: The Google Cloud credentials.
        """
        oidc_id_token = self.fetch_oidc_id_token()
        return self.exchange_token_for_google_credentials(oidc_id_token)

def main():
    workforce_pool_id = 'your_workforce_pool_id'
    provider_id = 'your_provider_id'
    url_to_return_oidc_id_token = 'http://localhost:5000/token'
    workforce_pool_user_project = 'your_workforce_pool_user_project'

    authenticator = GoogleCloudAuthenticator(
        workforce_pool_id, provider_id, url_to_return_oidc_id_token, workforce_pool_user_project
    )

    try:
        google_credentials = authenticator.authenticate()
        print("Google Cloud Credentials:", google_credentials)
    except requests.exceptions.RequestException as e:
        print(f"HTTP Request failed: {e}")

if __name__ == "__main__":
    main()

Explanation

  1. Class Definition: GoogleCloudAuthenticator encapsulates the authentication logic.

    • __init__ Method: Initializes the object with necessary parameters and constructs the audience, token_url, and subject_token_type.
    • fetch_oidc_id_token Method: Fetches the OIDC ID token from the specified URL, assuming the response is JSON formatted and the token is stored under the key id_token.
    • exchange_token_for_google_credentials Method: Exchanges the OIDC ID token for Google Cloud credentials.
    • authenticate Method: Orchestrates the fetching and exchanging of the token.
  2. Main Function: Creates an instance of GoogleCloudAuthenticator, sets up the necessary configuration, and performs the authentication process.

apvd commented 2 months ago

Hi Pedro, thank you so much for the detailed reply!

I agree it's best to use the official gcloud SDK from google to implement the token exchange process as you suggest on the client.

In order to make that work, the client library requires a source of OIDC tokens from an identity provider. (In the generated sample code, this is referred to as self.url_to_return_oidc_id_token). I would like to use authlib to implement that identity provider. The official google client library requires that the OIDC identity token be retrieved by a GET, rather than a POST. The client library controls the request, clients using the library can provide the target URL but not the HTTP method. Once it has retrieved the OIDC ID token, it will do the token exchange with Google STS for an access token, no problem.

Sounds like authlib OIDC identity provider support isn't intended to provide identity tokens to Google gcloud clients, so I'll find another way around. Thank you!

-Aaron

On Wed, Jul 10, 2024 at 6:56 PM Pedro Aguiar @.***> wrote:

This "URL-sourced Credential Flow" is indeed a custom OIDC flow by Google, which is likely there for legacy reasons, so at first glance none of the standard flows will suffice. However, implementing it yourself shouldn't be that hard given that you can use authlib's primitives (implementation provided at the end of this answer).

However, it's worth noting that such a flow is for creating Google credentials manually, which is something (next section on the same page)[ https://cloud.google.com/iam/docs/workforce-obtaining-short-lived-credentials#use_the_client_libraries] suggests avoiding if possible:

If you use a supported client library, you can configure the client library so that it generates Google credentials automatically. When possible, we recommend that you generate credentials automatically, so that you don't need to implement the token-exchange process yourself.

Since you created this issue in a Python package, I assume you're using Python, and Google offers an official client library whose usage is exemplified in that very section so you can cut to the chase:

from google.cloud import storageimport google.auth credentials, project = google.auth.default( scopes=['https://www.googleapis.com/auth/devstorage.read_only']) client = storage.Client( project="project-id", credentials=credentials)

Does that solve your problem?

ChatGPT-generated answer, which although I haven't tested myself, it looks like it should work. Complete Code

import requestsfrom authlib.integrations.requests_client import OAuth2Session class GoogleCloudAuthenticator: def init(self, workforce_pool_id, provider_id, url_to_return_oidc_id_token, workforce_pool_user_project): self.workforce_pool_id = workforce_pool_id self.provider_id = provider_id self.url_to_return_oidc_id_token = url_to_return_oidc_id_token self.workforce_pool_user_project = workforce_pool_user_project self.audience = f"//iam.googleapis.com/locations/global/workforcePools/{self.workforce_pool_id}/providers/{self.provider_id}" self.token_url = "https://sts.googleapis.com/v1/token" self.subject_token_type = "urn:ietf:params:oauth:token-type:id_token"

def fetch_oidc_id_token(self):
    """        Fetch the OIDC ID token from the specified URL.        Returns:        str: The OIDC ID token.        """
    response = requests.get(self.url_to_return_oidc_id_token)
    response.raise_for_status()
    return response.json().get('id_token')

def exchange_token_for_google_credentials(self, token):
    """        Exchange the OIDC ID token for Google Cloud credentials using Google's STS.        Args:        token (str): The OIDC ID token.        Returns:        dict: The response containing Google Cloud credentials.        """
    client = OAuth2Session()
    data = {
        'grant_type': 'urn:ietf:params:oauth:grant-type:token-exchange',
        'audience': self.audience,
        'subject_token': token,
        'subject_token_type': self.subject_token_type,
        'requested_token_type': 'urn:ietf:params:oauth:token-type:access_token',
        'scope': 'https://www.googleapis.com/auth/cloud-platform',
        'options': {
            'userProject': self.workforce_pool_user_project
        }
    }
    response = client.post(self.token_url, data=data)
    response.raise_for_status()
    return response.json()

def authenticate(self):
    """        Authenticate by fetching the OIDC ID token and exchanging it for Google Cloud credentials.        Returns:        dict: The Google Cloud credentials.        """
    oidc_id_token = self.fetch_oidc_id_token()
    return self.exchange_token_for_google_credentials(oidc_id_token)

def main(): workforce_pool_id = 'your_workforce_pool_id' provider_id = 'your_provider_id' url_to_return_oidc_id_token = 'http://localhost:5000/token' workforce_pool_user_project = 'your_workforce_pool_user_project'

authenticator = GoogleCloudAuthenticator(
    workforce_pool_id, provider_id, url_to_return_oidc_id_token, workforce_pool_user_project
)

try:
    google_credentials = authenticator.authenticate()
    print("Google Cloud Credentials:", google_credentials)
except requests.exceptions.RequestException as e:
    print(f"HTTP Request failed: {e}")
except Exception as e:
    print(f"An error occurred: {e}")

if name == "main": main()

Explanation

1.

Class Definition: GoogleCloudAuthenticator encapsulates the authentication logic.

  • init Method: Initializes the object with necessary parameters and constructs the audience, token_url, and subject_token_type.

    • fetch_oidc_id_token Method: Fetches the OIDC ID token from the specified URL, assuming the response is JSON formatted and the token is stored under the key id_token.
    • exchange_token_for_google_credentials Method: Exchanges the OIDC ID token for Google Cloud credentials.
    • authenticate Method: Orchestrates the fetching and exchanging of the token. 2.

    Main Function: Creates an instance of GoogleCloudAuthenticator, sets up the necessary configuration, and performs the authentication process.

— Reply to this email directly, view it on GitHub https://github.com/lepture/authlib/issues/658#issuecomment-2221841783, or unsubscribe https://github.com/notifications/unsubscribe-auth/ABGO6O5YCEJ6SY25VYE5Y4LZLXQ6TAVCNFSM6AAAAABKJZHCIWVHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZDEMRRHA2DCNZYGM . You are receiving this because you were mentioned.Message ID: @.***>