Update requests of device flow for public client

This commit makes a change in OIDC Device Flow to include the client_id
in the request body without performing basic authentication when no
client_secret is specified.

According to RFC8628, in the Device Authorization Request [1] and Device
Access Token Request [2], if the client is not authenticating with the
authorization server, the client_id should be included in the request
body.

In addition, this commit adds missing unit tests for the
OidcDeviceAuthorization.

[1] https://datatracker.ietf.org/doc/html/rfc8628#section-3.1
[2] https://datatracker.ietf.org/doc/html/rfc8628#section-3.4

Closes-bug: #2053965
Change-Id: Ieada594dc68e152e176969258f098f7d064801f4
This commit is contained in:
Tatsuya Hayashino 2024-07-08 09:54:52 +09:00
parent d552a9a1b7
commit 0b82d4283a
4 changed files with 162 additions and 3 deletions

View File

@ -635,11 +635,16 @@ class OidcDeviceAuthorization(_OidcBase):
:returns: a python dictionary containing the payload to be exchanged
:rtype: dict
"""
client_auth = (self.client_id, self.client_secret)
device_authz_endpoint = \
self._get_device_authorization_endpoint(session)
payload = {}
if self.client_secret:
client_auth = (self.client_id, self.client_secret)
payload = {}
else:
client_auth = None
payload = {'client_id': self.client_id}
if self.code_challenge_method:
self.code_challenge = self._generate_pkce_challenge()
payload.setdefault('code_challenge_method',
@ -695,7 +700,12 @@ class OidcDeviceAuthorization(_OidcBase):
_logger.warning(f"To authenticate please go to: "
f"{self.verification_uri_complete}")
client_auth = (self.client_id, self.client_secret)
if self.client_secret:
client_auth = (self.client_id, self.client_secret)
else:
client_auth = None
payload.setdefault('client_id', self.client_id)
access_token_endpoint = self._get_access_token_endpoint(session)
encoded_payload = urlparse.urlencode(payload)

View File

@ -10,6 +10,8 @@
# License for the specific language governing permissions and limitations
# under the License.
import base64
import time
from unittest import mock
import urllib
import uuid
@ -427,3 +429,133 @@ class OIDCTokenTests(utils.TestCase):
response = self.plugin.get_unscoped_auth_ref(self.session)
self.assertEqual(KEYSTONE_TOKEN_VALUE, response.auth_token)
class OIDCDeviceAuthorizationTest(BaseOIDCTests, utils.TestCase):
def setUp(self):
super(OIDCDeviceAuthorizationTest, self).setUp()
self.GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code'
self.DEVICE_AUTH_ENDPOINT = \
'https://localhost:8020/oidc/authorize/device'
self.plugin = oidc.OidcDeviceAuthorization(
self.AUTH_URL,
self.IDENTITY_PROVIDER,
self.PROTOCOL,
client_id=self.CLIENT_ID,
client_secret=self.CLIENT_SECRET,
access_token_endpoint=self.ACCESS_TOKEN_ENDPOINT,
device_authorization_endpoint=self.DEVICE_AUTH_ENDPOINT)
def test_get_device_authorization_request(self):
"""Test device authorization request."""
# Mock the output that creates the device code
self.requests_mock.post(
self.DEVICE_AUTH_ENDPOINT,
json=oidc_fixtures.DEVICE_CODE_RESP)
result = self.plugin.get_payload(self.session)
# Verify the request matches the expected structure
last_req = self.requests_mock.last_request
self.assertEqual(self.DEVICE_AUTH_ENDPOINT, last_req.url)
self.assertEqual('POST', last_req.method)
self.assertEqual(
self._basic_header(self.CLIENT_ID, self.CLIENT_SECRET),
last_req.headers.get('Authorization'))
self.assertEqual(oidc_fixtures.DEVICE_CODE_RESP["device_code"],
result["device_code"])
def test_get_device_authorization_request_without_client_secret(self):
"""Test device authorization request, without client_secret.
From RFC 8628 Section 3.1, if Basic authentication is not performed,
it is required to include the client_id in the request body.
"""
self.plugin.client_secret = None
# Mock the output that creates the device code
self.requests_mock.post(
self.DEVICE_AUTH_ENDPOINT,
json=oidc_fixtures.DEVICE_CODE_RESP)
result = self.plugin.get_payload(self.session)
payload = {'client_id': self.CLIENT_ID}
# Verify the request matches the expected structure
last_req = self.requests_mock.last_request
self.assertEqual(self.DEVICE_AUTH_ENDPOINT, last_req.url)
self.assertEqual('POST', last_req.method)
self.assertIsNone(last_req.headers.get('Authorization'))
encoded_payload = urllib.parse.urlencode(payload)
self.assertEqual(encoded_payload, last_req.body)
self.assertEqual(oidc_fixtures.DEVICE_CODE_RESP["device_code"],
result["device_code"])
def test_second_call_to_get_access_token(self):
"""Test Device Access Token Request."""
self._store_device_authorization_response()
# Mock the output that creates the access token
self.requests_mock.post(
self.ACCESS_TOKEN_ENDPOINT,
json=oidc_fixtures.ACCESS_TOKEN_VIA_PASSWORD_RESP)
# Prep all the values and send the request
payload = {'grant_type': self.GRANT_TYPE,
'device_code': self.plugin.device_code}
self.plugin._get_access_token(self.session, payload)
# Verify the request matches the expected structure
last_req = self.requests_mock.last_request
self.assertEqual(self.ACCESS_TOKEN_ENDPOINT, last_req.url)
self.assertEqual('POST', last_req.method)
self.assertEqual(
self._basic_header(self.CLIENT_ID, self.CLIENT_SECRET),
last_req.headers.get('Authorization'))
encoded_payload = urllib.parse.urlencode(payload)
self.assertEqual(encoded_payload, last_req.body)
def test_second_call_to_get_access_token_without_client_secret(self):
"""Device Access Token Request without client_secret.
From RFC 8628 Section 3.4, if Basic authentication is not performed,
it is required to include the client_id in the request body.
"""
self.plugin.client_secret = None
self._store_device_authorization_response()
# Mock the output that creates the access token
self.requests_mock.post(
self.ACCESS_TOKEN_ENDPOINT,
json=oidc_fixtures.ACCESS_TOKEN_VIA_PASSWORD_RESP)
# Prep all the values and send the request
payload = {'grant_type': self.GRANT_TYPE,
'device_code': self.plugin.device_code,
'client_id': self.CLIENT_ID}
self.plugin._get_access_token(self.session, payload)
# Verify the request matches the expected structure
last_req = self.requests_mock.last_request
self.assertEqual(self.ACCESS_TOKEN_ENDPOINT, last_req.url)
self.assertEqual('POST', last_req.method)
self.assertIsNone(last_req.headers.get('Authorization'))
encoded_payload = urllib.parse.urlencode(payload)
self.assertEqual(encoded_payload, last_req.body)
def _basic_header(self, username, password):
user_pass = '{}:{}'.format(username, password).encode('utf-8')
encoded_credentials = base64.b64encode(user_pass).decode('utf-8')
return 'Basic {}'.format(encoded_credentials)
def _store_device_authorization_response(self):
self.plugin.expires_in = int(
oidc_fixtures.DEVICE_CODE_RESP["expires_in"])
self.plugin.timeout = time.time() + self.plugin.expires_in
self.plugin.device_code = oidc_fixtures.DEVICE_CODE_RESP["device_code"]
self.plugin.interval = int(oidc_fixtures.DEVICE_CODE_RESP["interval"])
self.plugin.user_code = oidc_fixtures.DEVICE_CODE_RESP["user_code"]
self.plugin.verification_uri = \
oidc_fixtures.DEVICE_CODE_RESP["verification_uri"]
self.plugin.verification_uri_complete = \
oidc_fixtures.DEVICE_CODE_RESP["verification_uri_complete"]

View File

@ -50,6 +50,16 @@ ACCESS_TOKEN_VIA_AUTH_GRANT_RESP = {
"id_token": "eyJhbGciOiJSUzI1Ni8hOYHuZT8dt_yynmJVhcU"
}
DEVICE_CODE_RESP = {
'device_code': "fRtNXLxmRy4iEzLsbAV96pMFgOMEUCPZmAMepjLrvQ",
'user_code': "ABCD-EFGH",
'verification_uri': "https://localhost:8020/oidc/authorize/device",
'verification_uri_complete':
"https://localhost:8020/oidc/authorize/device?user_code=ABCD-EFGH",
'expires_in': 1800,
'interval': 5
}
DISCOVERY_DOCUMENT = {
"authorization_endpoint": "https://localhost:8020/oidc/authorize",
"claims_supported": [

View File

@ -0,0 +1,7 @@
---
fixes:
- |
[`bug 2053965 <https://bugs.launchpad.net/keystoneauth/+bug/2053965>`_]
OIDC Device Authorization Flow: In accordance with RFC8628, for public
clients that do not have a client_secret, the client_id is included in
the request bodies.