Skip to content
56 changes: 55 additions & 1 deletion devstack/tools/oidc/setup_keycloak_client.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import http
import os
import requests

Expand Down Expand Up @@ -34,6 +35,17 @@ def _admin_auth(self, realm):
self.session.headers.update(headers)
return r

def _get_client_uuid(self, realm, client_id):
resp = self.session.get(
self.construct_url(realm, 'clients'),
params={'clientId': client_id},
)
resp.raise_for_status()
for client in resp.json():
if client.get('clientId') == client_id:
return client['id']
return None

def create_client(self, realm, client_id, client_secret, redirect_uris):
self._admin_auth(realm)
data = {
Expand All @@ -43,7 +55,49 @@ def create_client(self, realm, client_id, client_secret, redirect_uris):
'implicitFlowEnabled': True,
'directAccessGrantsEnabled': True,
}
return self.session.post(self.construct_url(realm, 'clients'), json=data)
resp = self.session.post(
self.construct_url(realm, 'clients'), json=data
)
if resp.status_code == http.HTTPStatus.CONFLICT:
# Client already exists from a previous run; reuse it.
client_uuid = self._get_client_uuid(realm, client_id)
if client_uuid is None:
resp.raise_for_status()
else:
resp.raise_for_status()
# Keycloak returns 201 with the new client's UUID in the
# Location header: .../admin/realms/<realm>/clients/<uuid>
client_uuid = resp.headers['Location'].rsplit('/', 1)[-1]
# Since Keycloak 26.6.2 (CVE-2026-37979) the OAuth2 token
# introspection endpoint requires the introspecting client to
# be present in the access token's "aud" claim. Apache's
# mod_auth_openidc uses this same client to introspect bearer
# tokens it receives from federated users, so we add an
# audience protocol mapper that lists the client itself in
# "aud". Without this, introspection returns active=false and
# mod_auth_openidc rejects the token with HTTP 401.
self._add_audience_mapper(realm, client_uuid, client_id)
return resp

def _add_audience_mapper(self, realm, client_uuid, audience_client_id):
mapper = {
'name': f'{audience_client_id}-audience',
'protocol': 'openid-connect',
'protocolMapper': 'oidc-audience-mapper',
'config': {
'included.client.audience': audience_client_id,
'access.token.claim': 'true',
'id.token.claim': 'false',
'introspection.token.claim': 'true',
},
}
path = f'clients/{client_uuid}/protocol-mappers/models'
resp = self.session.post(self.construct_url(realm, path), json=mapper)
if resp.status_code == http.HTTPStatus.CONFLICT:
# Mapper already present from a previous run; nothing to do.
return resp
resp.raise_for_status()
return resp


def main():
Expand Down
7 changes: 7 additions & 0 deletions keystone/api/_shared/EC2_S3_Resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,13 @@ def handle_authenticate(self):
app_cred = ac_client.get_application_credential(
cred_data['app_cred_id'])
roles = [r['id'] for r in app_cred['roles']]
if cred_data['project_id'] != app_cred['project_id']:
raise ks_exceptions.Unauthorized(
_(
'EC2 credential project does not match the '
'application credential project.'
)
)
elif cred_data['access_token_id']:
access_token = PROVIDERS.oauth_api.get_access_token(
cred_data['access_token_id'])
Expand Down
32 changes: 28 additions & 4 deletions keystone/api/credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@
ENFORCER = rbac_enforcer.RBACEnforcer


def _check_unrestricted_application_credential(token):
if 'application_credential' in token.methods:
if not token.application_credential['unrestricted']:
action = _(
"Using method 'application_credential' is not "
"allowed for managing additional credentials."
)
raise exception.ForbiddenAction(action=action)


def _build_target_enforcement():
target = {}
try:
Expand Down Expand Up @@ -156,11 +166,25 @@ def post(self):
action='identity:create_credential', target_attr=target
)
validation.lazy_validate(schema.credential_create, credential)
token = self.auth_context['token']
if credential.get('type', '').lower() == 'ec2':
_check_unrestricted_application_credential(token)
trust_id = getattr(self.oslo_context, 'trust_id', None)
app_cred_id = getattr(
self.auth_context['token'], 'application_credential_id', None)
access_token_id = getattr(
self.auth_context['token'], 'access_token_id', None)
app_cred_id = getattr(token, 'application_credential_id', None)
access_token_id = getattr(token, 'access_token_id', None)
if (
app_cred_id is not None
and credential.get('type', '').lower() == 'ec2'
):
ac_api = PROVIDERS.application_credential_api
app_cred = ac_api.get_application_credential(app_cred_id)
if credential.get('project_id') != app_cred['project_id']:
action = _(
'EC2 credential project_id must match the '
'project of the application credential used '
'to authenticate'
)
raise exception.ForbiddenAction(action=action)
ref = self._assign_unique_id(
self._normalize_dict(credential),
trust_id=trust_id, app_cred_id=app_cred_id,
Expand Down
11 changes: 11 additions & 0 deletions keystone/api/os_oauth1.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,17 @@ def put(self, request_token_id):
raise exception.Forbidden(
_('Cannot authorize a request token with a token issued via '
'delegation.'))
auth_context = flask.request.environ.get(
authorization.AUTH_CONTEXT_ENV, {}
)
token = auth_context.get('token')
if token and 'application_credential' in token.methods:
raise exception.Forbidden(
_(
'Cannot authorize a request token with a token issued via '
'delegation.'
)
)

req_token = PROVIDERS.oauth_api.get_request_token(request_token_id)

Expand Down
178 changes: 177 additions & 1 deletion keystone/tests/unit/test_v3_credential.py
Original file line number Diff line number Diff line change
Expand Up @@ -688,9 +688,11 @@ def test_app_cred_ec2_credential(self):

Call ``POST /credentials``.
"""
# Create the app cred
# Create an unrestricted app cred (restricted app creds are
# blocked from creating EC2 credentials)
ref = unit.new_application_credential_ref(roles=[{'id': self.role_id}])
del ref['id']
ref['unrestricted'] = True
r = self.post('/users/%s/application_credentials' % self.user_id,
body={'application_credential': ref})
app_cred = r.result['application_credential']
Expand Down Expand Up @@ -743,6 +745,133 @@ def test_app_cred_ec2_credential(self):
token=token_id,
expected_status=http.client.CONFLICT)

def _get_app_cred_token(self, unrestricted=False):
"""Create an application credential and return its token."""
ref = unit.new_application_credential_ref(roles=[{'id': self.role_id}])
del ref['id']
if unrestricted:
ref['unrestricted'] = True
r = self.post(
f'/users/{self.user_id}/application_credentials',
body={'application_credential': ref},
)
app_cred = r.result['application_credential']
auth_data = self.build_authentication_request(
app_cred_id=app_cred['id'], secret=app_cred['secret']
)
r = self.v3_create_token(auth_data)
return r.headers.get('X-Subject-Token')

def test_restricted_app_cred_cannot_create_ec2_credential(self):
"""Test that a restricted app cred cannot create EC2 credentials.

A restricted application credential must not be allowed to
create EC2 credentials via POST /credentials either, as this
would bypass the guard on the OS-EC2 endpoint.
"""
token_id = self._get_app_cred_token(unrestricted=False)
blob, ref = unit.new_ec2_credential(
user_id=self.user_id, project_id=self.project_id
)
self.post(
'/credentials',
body={'credential': ref},
token=token_id,
expected_status=http.client.FORBIDDEN,
)

def test_app_cred_ec2_credential_cross_project_forbidden(self):
"""EC2 credential project_id must match the app cred project.

An unrestricted app cred scoped to project A must not be used to
create an EC2 credential targeting a different project B.

Call ``POST /credentials``.
"""
token_id = self._get_app_cred_token(unrestricted=True)

other_project = unit.new_project_ref(domain_id=self.domain_id)
PROVIDERS.resource_api.create_project(
other_project['id'], other_project
)

_, ec2_ref = unit.new_ec2_credential(
user_id=self.user_id, project_id=other_project['id']
)
self.post(
'/credentials',
body={'credential': ec2_ref},
token=token_id,
expected_status=http.client.FORBIDDEN,
)

def test_app_cred_ec2_auth_cross_project_rejected(self):
"""EC2 auth is rejected when credential project differs from app cred.

A pre-existing EC2 credential whose project_id does not match the
linked application credential's project must be rejected at
authentication time, preventing cross-project lateral movement.

Call ``POST /ec2tokens``.
"""
ref = unit.new_application_credential_ref(roles=[{'id': self.role_id}])
del ref['id']
r = self.post(
f'/users/{self.user_id}/application_credentials',
body={'application_credential': ref},
)
app_cred = r.result['application_credential']

other_project = unit.new_project_ref(domain_id=self.domain_id)
PROVIDERS.resource_api.create_project(
other_project['id'], other_project
)

# Bypass the API to plant a credential with a mismatched project_id.
# This simulates a credential that existed before the creation-time
# check was added, or one created via a direct DB write.
blob = {
'access': uuid.uuid4().hex,
'secret': uuid.uuid4().hex,
'trust_id': None,
'app_cred_id': app_cred['id'],
}
_, ec2_ref = unit.new_ec2_credential(
user_id=self.user_id, project_id=other_project['id'], blob=blob
)
PROVIDERS.credential_api.create_credential(ec2_ref['id'], ec2_ref)

signer = ec2_utils.Ec2Signer(blob['secret'])
params = {
'SignatureMethod': 'HmacSHA256',
'SignatureVersion': '2',
'AWSAccessKeyId': blob['access'],
}
request = {
'host': 'foo',
'verb': 'GET',
'path': '/bar',
'params': params,
}
sig_ref = {
'access': blob['access'],
'signature': signer.generate(request),
'host': 'foo',
'verb': 'GET',
'path': '/bar',
'params': params,
}
PROVIDERS.assignment_api.create_system_grant_for_user(
self.user_id, self.role_id
)
token = self.get_system_scoped_token()
self.post(
'/ec2tokens',
body={'ec2Credentials': sig_ref},
token=token,
expected_status=http.client.UNAUTHORIZED,
)


class TestCredentialAccessToken(CredentialBaseTestCase):
"""Test credential with access token."""
Expand Down Expand Up @@ -994,3 +1123,50 @@ def test_ec2_delete_credential(self):
self.assertRaises(exception.CredentialNotFound,
PROVIDERS.credential_api.get_credential,
cred_from_credential_api[0]['id'])

def _get_app_cred_token(self, unrestricted=False):
"""Create an application credential and return a token for it."""
ref = unit.new_application_credential_ref(roles=[{'id': self.role_id}])
del ref['id']
if unrestricted:
ref['unrestricted'] = True
r = self.post(
f'/users/{self.user_id}/application_credentials',
body={'application_credential': ref},
)
app_cred = r.result['application_credential']
auth_data = self.build_authentication_request(
app_cred_id=app_cred['id'], secret=app_cred['secret']
)
r = self.v3_create_token(auth_data)
return r.headers.get('X-Subject-Token')

def test_ec2_create_credential_with_restricted_app_cred(self):
"""Test that a restricted app cred cannot create EC2 credentials.

A restricted application credential must not be allowed to create
EC2 credentials, as this would bypass the role restriction and
grant full user access to S3.
"""
token_id = self._get_app_cred_token(unrestricted=False)
uri = self._get_ec2_cred_uri()
self.post(
uri,
body={'tenant_id': self.project_id},
token=token_id,
expected_status=http.client.FORBIDDEN,
)

def test_ec2_create_credential_with_unrestricted_app_cred(self):
"""Test that an unrestricted app cred can create EC2 credentials."""
token_id = self._get_app_cred_token(unrestricted=True)
uri = self._get_ec2_cred_uri()
r = self.post(
uri,
body={'tenant_id': self.project_id},
token=token_id,
expected_status=http.client.CREATED,
)
ec2_cred = r.result['credential']
self.assertEqual(self.user_id, ec2_cred['user_id'])
self.assertEqual(self.project_id, ec2_cred['tenant_id'])
24 changes: 24 additions & 0 deletions keystone/tests/unit/test_v3_oauth1.py
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,30 @@ def test_trust_token_cannot_list_request_tokens(self):
self.get(url, token=trust_token,
expected_status=http.client.FORBIDDEN)

def _create_app_cred_get_token(self):
ref = unit.new_application_credential_ref(roles=[{'id': self.role_id}])
del ref['id']
r = self.post(
f'/users/{self.user_id}/application_credentials',
body={'application_credential': ref},
)
app_cred = r.result['application_credential']
auth_data = self.build_authentication_request(
app_cred_id=app_cred['id'], secret=app_cred['secret']
)
return self.get_requested_token(auth_data)

def test_app_cred_token_cannot_authorize_request_token(self):
app_cred_token = self._create_app_cred_get_token()
url = self._approve_request_token_url()
body = {'roles': [{'id': self.role_id}]}
self.put(
url,
body=body,
token=app_cred_token,
expected_status=http.client.FORBIDDEN,
)


class FernetAuthTokenTests(AuthTokenTests, OAuthFlowTests):

Expand Down