Starlette OIDC Integration: Response Mode & Type Fixes
Hey guys! Today, we're diving deep into some tricky issues encountered while integrating Starlette with OIDC (OpenID Connect) using Authlib. Specifically, we're going to tackle problems related to response_mode
and response_type
in the Starlette integration's OAuth2 code. If you're scratching your head over similar errors, you're in the right place. Let’s break it down and get you back on track!
The Starlette OIDC Integration Bug Hunt
So, here's the deal. The StarletteOAuth2App.authorize_access_token
method in the integrations/starlette_client
module has a few quirks that can cause headaches. We'll explore these issues and, more importantly, how to fix them.
1. Handling OIDC Callbacks with response_mode=form_post
The first issue arises when the response_mode
is set to form_post
. The current implementation isn't equipped to handle this scenario properly. Basically, when using form_post
, the authorization server sends the response parameters (like code
and state
) in the body of an HTML form, which is then POSTed to the client's callback URL. The original code only checks for GET requests, which means it misses these crucial parameters.
To understand why this is important, think about the security implications. The form_post
response mode is designed to prevent sensitive information from being exposed in the URL, which can happen with the default query
response mode (where parameters are appended to the URL). This is especially critical when dealing with identity tokens and other sensitive data.
The Fix:
Luckily, the fix is relatively straightforward. We need to modify the authorize_access_token
method to check the request method and extract the parameters accordingly. Here’s a snippet that does the trick, drawing inspiration from the Flask integration:
if request.method == "GET":
params = {
"code": request.query_params.get("code"),
"state": request.query_params.get("state"),
}
else:
async with request.form() as form:
params = {
"code": form.get("code"),
"state": form.get("state"),
}
This code snippet checks if the request method is GET or POST. If it's a GET request, it retrieves the parameters from the query string. If it's a POST request, it reads the form data. This ensures that we can correctly handle OIDC callbacks using response_mode=form_post
. By handling both GET and POST requests, this ensures that the application can correctly process the authorization response regardless of the response_mode
. The state
parameter is crucial for preventing CSRF attacks, so it's vital to handle it correctly. The code
parameter, on the other hand, is the authorization code that will be exchanged for an access token or an ID token.
2. Handling Callbacks Without code
The second issue is a bit more nuanced. In certain OIDC flows, you might not receive a code
in the callback. For instance, if you're only requesting an id_token
(using response_type=id_token
), the authorization server won't include a code
. The current implementation, however, throws an error if code
is missing, which is not ideal.
To illustrate, consider a scenario where you're using OIDC for single sign-on (SSO). You only need to verify the user's identity, so you request an id_token
. The authorization server complies and sends back the id_token
and state
, but no code
. The current implementation would incorrectly raise an error in this case. This issue underscores the importance of adhering to the OIDC specification and handling different response types correctly. The absence of the code
does not necessarily indicate an error; it simply means that the flow is designed to return an ID token directly.
The Problematic Commit:
Commit a53173e2e4edd09e4e25e82432050e6af11e6873
seems to be the culprit here. It introduces a check that assumes the presence of code
in all callbacks. This assumption breaks the flow when response_type
doesn't include code
.
The Solution:
The fix here involves making the presence of code
optional. We need to modify the code to handle cases where code
is not present in the callback. This typically involves checking the response_type
and adjusting the logic accordingly. The corrected implementation must differentiate between authorization flows that require a code exchange (like the authorization code flow) and those that do not (like the implicit flow or the hybrid flow when only requesting an ID token). A conditional check based on the response_type
would be a suitable approach.
3. Optional Access Token Requests
Lastly, there's the issue of access tokens. Sometimes, you might not need an access token at all. For example, if you're only using OIDC for authentication, the id_token
might be sufficient. In such cases, requesting an access token (which requires a token endpoint) should be optional.
In the reported scenario, the user wasn't asking for an access token (response_type
didn't include token
), and the service they were authenticating against didn't even support a token endpoint. Yet, the code was still trying to fetch an access token, leading to an error.
The Error Stack:
File "/Volumes/work_workspace/Projects/aacc-lti_forms/src/lti_forms/app.py", line 171, in launch
token = await client.authorize_access_token(request)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Volumes/work_workspace/Projects/aacc-lti_forms/.venv/lib/python3.12/site-packages/authlib/integrations/starlette_client/apps.py", line 95, in authorize_access_token
token = await self.fetch_access_token(**params, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Volumes/work_workspace/Projects/aacc-lti_forms/.venv/lib/python3.12/site-packages/authlib/integrations/base_client/async_app.py", line 133, in fetch_access_token
token = await client.fetch_token(token_endpoint, **params)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Volumes/work_workspace/Projects/aacc-lti_forms/.venv/lib/python3.12/site-packages/authlib/integrations/httpx_client/oauth2_client.py", line 163, in _fetch_token
resp = await self.post(
^^^^^^^^^^^^^^^^
File "/Volumes/work_workspace/Projects/aacc-lti_forms/.venv/lib/python3.12/site-packages/httpx/_client.py", line 1859, in post
return await self.request(
^^^^^^^^^^^^^^^^^^^
File "/Volumes/work_workspace/Projects/aacc-lti_forms/.venv/lib/python3.12/site-packages/authlib/integrations/httpx_client/oauth2_client.py", line 119, in request
return await super().request(method, url, auth=auth, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Volumes/work_workspace/Projects/aacc-lti_forms/.venv/lib/python3.12/site-packages/httpx/_client.py", line 1527, in request
request = self.build_request(
^^^^^^^^^^^^^^^^^^^
File "/Volumes/work_workspace/Projects/aacc-lti_forms/.venv/lib/python3.12/site-packages/httpx/_client.py", line 366, in build_request
url = self._merge_url(url)
^^^^^^^^^^^^^^^^^^^^
File "/Volumes/work_workspace/Projects/aacc-lti_forms/.venv/lib/python3.12/site-packages/httpx/_client.py", line 396, in _merge_url
merge_url = URL(url)
^^^^^^^^
File "/Volumes/work_workspace/Projects/aacc-lti_forms/.venv/lib/python3.12/site-packages/httpx/_urls.py", line 121, in __init__
raise TypeError(
TypeError: Invalid type for url. Expected str or httpx.URL, got <class 'NoneType'>: None
The Root Cause:
The error stack clearly shows a TypeError: Invalid type for url. Expected str or httpx.URL, got <class 'NoneType'>: None
. This indicates that the token_endpoint
is None
, which means the code is trying to fetch an access token without a configured endpoint.
The Fix:
To address this, we need to make the access token request optional. This can be achieved by checking the response_type
and skipping the token fetch if it doesn't include token
. A more robust solution would involve checking if an access token is actually needed based on the response_type
and the scopes requested. If only an ID token is requested, the access token retrieval step should be bypassed.
Reproducing the Issues
While a minimal reproducible example wasn't provided, the issues can be reproduced by configuring a Starlette application with Authlib and attempting the following:
- Use
response_mode=form_post
in the authorization request. - Request only an
id_token
(i.e.,response_type=id_token
). - Do not configure an access token endpoint.
Expected Behavior
The expected behavior is that the response should be correctly validated, and the id_token
should be parsed, allowing for session setup to complete without errors. A successful outcome involves correctly handling the OIDC callback, extracting the necessary information (like the ID token and state), and proceeding with the application's logic without attempting to fetch an access token when it's not required.
Environment
These issues were encountered in the following environment:
- OS: macOS 26
- Python Version: 3.12.11
- Authlib Version: 1.6.1 (from pip) with patches for issues 1 and 3, and HEAD to check for fixes related to issue 2.
Additional Context
This integration is part of an application that uses OIDC specifically for authentication within the LTI 1.3 framework (commonly used in higher education). The context of LTI 1.3 is relevant because it often involves specific requirements for authentication and authorization flows. Understanding the underlying standards and specifications helps in diagnosing and resolving integration issues.
Final Thoughts
Whew! That was a lot, guys! We've covered three key issues in the Starlette OIDC integration related to response_mode
and response_type
. By understanding these issues and applying the suggested fixes, you'll be well-equipped to handle OIDC authentication in your Starlette applications. Keep experimenting, keep learning, and happy coding!