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:
parent
d552a9a1b7
commit
0b82d4283a
@ -635,11 +635,16 @@ class OidcDeviceAuthorization(_OidcBase):
|
|||||||
:returns: a python dictionary containing the payload to be exchanged
|
:returns: a python dictionary containing the payload to be exchanged
|
||||||
:rtype: dict
|
:rtype: dict
|
||||||
"""
|
"""
|
||||||
client_auth = (self.client_id, self.client_secret)
|
|
||||||
device_authz_endpoint = \
|
device_authz_endpoint = \
|
||||||
self._get_device_authorization_endpoint(session)
|
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:
|
if self.code_challenge_method:
|
||||||
self.code_challenge = self._generate_pkce_challenge()
|
self.code_challenge = self._generate_pkce_challenge()
|
||||||
payload.setdefault('code_challenge_method',
|
payload.setdefault('code_challenge_method',
|
||||||
@ -695,7 +700,12 @@ class OidcDeviceAuthorization(_OidcBase):
|
|||||||
_logger.warning(f"To authenticate please go to: "
|
_logger.warning(f"To authenticate please go to: "
|
||||||
f"{self.verification_uri_complete}")
|
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)
|
access_token_endpoint = self._get_access_token_endpoint(session)
|
||||||
encoded_payload = urlparse.urlencode(payload)
|
encoded_payload = urlparse.urlencode(payload)
|
||||||
|
|
||||||
|
@ -10,6 +10,8 @@
|
|||||||
# License for the specific language governing permissions and limitations
|
# License for the specific language governing permissions and limitations
|
||||||
# under the License.
|
# under the License.
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import time
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
import urllib
|
import urllib
|
||||||
import uuid
|
import uuid
|
||||||
@ -427,3 +429,133 @@ class OIDCTokenTests(utils.TestCase):
|
|||||||
|
|
||||||
response = self.plugin.get_unscoped_auth_ref(self.session)
|
response = self.plugin.get_unscoped_auth_ref(self.session)
|
||||||
self.assertEqual(KEYSTONE_TOKEN_VALUE, response.auth_token)
|
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"]
|
||||||
|
@ -50,6 +50,16 @@ ACCESS_TOKEN_VIA_AUTH_GRANT_RESP = {
|
|||||||
"id_token": "eyJhbGciOiJSUzI1Ni8hOYHuZT8dt_yynmJVhcU"
|
"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 = {
|
DISCOVERY_DOCUMENT = {
|
||||||
"authorization_endpoint": "https://localhost:8020/oidc/authorize",
|
"authorization_endpoint": "https://localhost:8020/oidc/authorize",
|
||||||
"claims_supported": [
|
"claims_supported": [
|
||||||
|
7
releasenotes/notes/bug-2053965-b9717c6a8c058956.yaml
Normal file
7
releasenotes/notes/bug-2053965-b9717c6a8c058956.yaml
Normal 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.
|
Loading…
Reference in New Issue
Block a user