Authentication flow with Keystone and OpenID Connect - with cURL
Posted on Wed 12 June 2024 in work
Recently I was tasked to provide an example curl commands to realize the authentication process required to get an OpenStack Identity (Keystone) token for a user federated via OpenID Connect. Usually we tend to use already established libraries like keystoneauth or gophercloud for that, but since I not so long ago had to dive into OpenID Connect anyway, this posed an interesting task.
I use Mirantis OpenStack for Kubernetes (naturally :-) ), that integrates Keystone with Keycloak via OpenID Connect, OpenStack release is Caracal.
Pre-requisites
- OpenStack deployed and configured to use federation via OpenID Connect.
- Account in the Identity Provider allowing you to login to OpenStack.
- curl and jq installed and available.
Required access info
Obtain required access info. The commands below expect the following environment variables to be available.
- OS_AUTH_URL
- the OpenStack Identity endpoint address. Note that examples below expect a versioned endpoint (ends with /v3), adapt URLs accordingly if yours is not versioned.
- OS_DISCOVERY_ENDPOINT
- the URL of OpenID configuration discovery of the Identity Provider configured in Keystone, usually ends with .../.well-known/openid-configuration.
- OS_IDENTITY_PROVIDER
- Name of the identity provider configured in Keystone.
- OS_PROTOCOL
- Name of the protocol configured for this Identity Provider in Keystone.
- OS_CLIENT_ID
- The client name to use when contacting Identity Provider. Note - this is NOT your OpenStack login.
- OS_CLIENT_SECRET
- The password to use for the Identity Provider client. Usually it must be kept secret, as in standard Web SSO application of OpenID Connect, but here, as with Kubernetes + OpenID Connect, the CLI client is required to know it. Some Identity Providers allow to create clients without secrets (for example Keycloak does allow that).
- OS_OPENID_SCOPE
- The scope to use for authentication, governs how much info about you the Identity Provider will pass to the Service Provider - in this case you :-) At least (and usually only) must be openid.
- OS_USERNAME
- Your username with Identity Provider you want to login to OpenStack with.
- OS_PASSWORD
- Your password for your account with the Identity Provider.
- OS_PROJECT_NAME
- The name of the OpenStack project you want to authenticate to.
- OS_PROJECT_DOMAIN_ID
- ID of the OpenStack Identity domain that project belongs to.
Get it from Horizon
The easiest way to obtain those is to login via federation to Horizon, and download the RC file from the UI:
source this file into the active shell, it will ask you for your password
source <project>-openrc.sh
In what follows, I am assuming you've done that already, so all the necessary environment variables are present.
Authentication Flow
Now let's start with curl-ing. I use -sk to silence the progress indicator and, since I use development toy environment and self-signed TLS certificates, ignore TLS verification failures.
I am following the calls that would've been made when using keystoneauth library with v3oidcpassword authentication type.
Get OIDC token endpoint
Using the discovery endpoint, find the URL of token endpoint:
token_endpoint=$(curl -sk -X GET $OS_DISCOVERY_ENDPOINT | jq -r .token_endpoint)
Get the OIDC access token
access_token=$(curl -sk \
-X POST $token_endpoint \
-u $OS_CLIENT_ID:$OS_CLIENT_SECRET \
-d "username=${OS_USERNAME}&password=${OS_PASSWORD}&scope=${OS_OPENID_SCOPE}&grant_type=password" \
-H "Content-Type: application/x-www-form-urlencoded" \
| jq -r .access_token)
The trick is the Content-Type - as per the OpenID Connect RFC this is how it must be done with Form Serialization, not JSON. The client id and client secret are used for HTTP Basic Authentication, again, as per that RFC.
Get the unscoped Keystone token
Now the OpenStack part. In OpenStack, tokens are issued and valid in various "scopes" - project, domain, system or unscoped.
With federation, API user is expected to first exchange the Identity Provider token for unscoped OpenStack Identity token:
unscoped_token=$(curl -sik \
-I \
-X POST $OS_AUTH_URL/OS-FEDERATION/identity_providers/${OS_IDENTITY_PROVIDER}/protocols/${OS_PROTOCOL}/auth \
-H "Authorization: Bearer $access_token" \
| grep x-subject-token \
| awk '{print $2}' \
| tr -d '\r')
(the result of grep + awk has new line in the end, so need to trim that out to put that value properly into JSON later).
Here already we have some inconveniences with Bash: the token arrives in the header, but the response body (in JSON) also has some info. We are ignoring it, but it may be quite useful in some applications.
Discover authentication scopes (optional)
If you do not have the intended scope of authentication at hand - project or domain or system - you can now discover the available to you scopes by making the following requests with unscoped token:
curl -sik $OS_AUTH_URL/auth/projects -H "X-Auth-Token: $unscoped_token" | jq .projects
curl -sik $OS_AUTH_URL/auth/domains -H "X-Auth-Token: $unscoped_token" | jq .domains
curl -sik $OS_AUTH_URL/auth/system -H "X-Auth-Token: $unscoped_token" | jq .system
Prepare JSON for scoped token request
Generating JSON in Bash is very awkward due to double-quotes and lots of escaping... just save the request JSON body to file (obviously not secure):
token_request=$(mktemp)
cat > $token_request << EOJSON
{
"auth": {
"identity": {
"methods": [
"token"
],
"token": {
"id": "$unscoped_token"
}
},
"scope": {
"project": {
"domain": {
"id": "$OS_PROJECT_DOMAIN_ID"
},
"name": "$OS_PROJECT_NAME"
}
}
}
}
EOJSON
Get scoped token
Biggest disadvantages here, as again, the token is in the headers, but the response body contains a lot of useful info, including auth info (UUIDs of project, domain etc, group assignments, roles if explicit), and the Identity Catalog to discover the actual URL of the service endpoints we want to acces with the received token. Again, we skip all that useful info and only fetch the token:
scoped_token=$(curl -sik \
-X POST $OS_AUTH_URL/auth/tokens \
-d "@$token_request" -H "Content-Type: application/json" \
| grep x-subject-token \
| awk '{print $2}' \
| tr -d '\r')
Remove the temporary file with token request body (tiny security improvement):
rm $token_request
Use scoped token to make request to an OpenStack service
Here hardcoded endpoint is used, however base part of it could've been discovered from the response body of the previous request.
Get the list of available images:
curl -sk \
-X GET https://glance.it.just.works/v2/images \
-H "X-Auth-Token: $scoped_token" \
| jq .images
I specifically use Glance in the example as it has no project UUID in the endpoint, but many more services will need that, so their endpoints are better to discover from the catalog that was in the body of the response when we got ourselves the scoped token in the previous step.
The same but in Python
For comparison here are examples using Python:
requests only
first, very manual example using requests only, but now with proper discovery of service endpoint:
#!/usr/bin/env python
import os
import requests
fed_auth = {
"os_discovery_endpoint": os.getenv("OS_DISCOVERY_ENDPOINT"),
"os_identity_provider": os.getenv("OS_IDENTITY_PROVIDER"),
"os_protocol": os.getenv("OS_PROTOCOL"),
"os_openid_scope": os.getenv("OS_OPENID_SCOPE"),
"os_client_secret": os.getenv("OS_CLIENT_SECRET"),
"os_client_id": os.getenv("OS_CLIENT_ID"),
"os_username": os.getenv("OS_USERNAME"),
"os_password": os.getenv("OS_PASSWORD"),
"os_project_domain_id": os.getenv("OS_PROJECT_DOMAIN_ID"),
"os_project_name": os.getenv("OS_PROJECT_NAME"),
"os_auth_url": os.getenv("OS_AUTH_URL"),
"os_region_name": os.getenv("OS_REGION_NAME"),
"os_interface": os.getenv("OS_INTERFACE"),
"os_insecure": True,
}
VERIFY = None
if fed_auth.get("os_cacert"):
VERIFY = fed_auth["os_cacert"]
elif fed_auth.get("os_insecure") is True:
VERIFY = False
# discover OIDC provider token endpoint
discovery_resp = requests.get(fed_auth["os_discovery_endpoint"], verify=VERIFY)
token_endpoint = discovery_resp.json()["token_endpoint"]
# get OIDC access token
access_req_data = "username={os_username}&password={os_password}&scope={os_openid_scope}&grant_type=password".format(**fed_auth)
access_resp = requests.post(
token_endpoint,
verify=VERIFY,
headers={"Content-Type": "application/x-www-form-urlencoded"},
data=access_req_data,
auth=(fed_auth["os_client_id"], fed_auth["os_client_secret"]),
)
access_token = access_resp.json()["access_token"]
# Exchange OIDC access token for OpenStack Identity unscoped token
unscoped_token_resp = requests.post(
"{os_auth_url}/OS-FEDERATION/identity_providers/{os_identity_provider}/protocols/{os_protocol}/auth".format(**fed_auth),
headers={"Authorization": f"Bearer {access_token}"},
verify=VERIFY,
)
unscoped_token = unscoped_token_resp.headers.get("x-subject-token")
# (optional) use unscoped token to discover possible authorizaton scopes
available_project_scopes_resp = requests.get(
"{os_auth_url}/auth/projects".format(**fed_auth),
verify=VERIFY,
headers={"X-Auth-Token": unscoped_token},
)
# exchange unscoped token and scope info for scoped token
scoped_auth_req = {
"auth": {
"identity": {
"methods": [
"token"
],
"token": {
"id": unscoped_token
}
},
"scope": {
"project": {
"domain": {
"id": fed_auth["os_project_domain_id"]
},
"name": fed_auth["os_project_name"]
}
}
}
}
scoped_token_resp = requests.post(
"{os_auth_url}/auth/tokens".format(**fed_auth),
verify=VERIFY,
headers={"Content-Type": "application/json"},
json=scoped_auth_req,
)
# more info on user, its roles and groups is in the JSON body of the response
scoped_token = scoped_token_resp.headers.get("x-subject-token")
catalog = scoped_token_resp.json()["token"]["catalog"]
interface = fed_auth.get("os_interface", "public")
region = fed_auth.get("os_region_name", "RegionOne")
# discover endpoint of the Image service
image_service = [s for s in catalog if s["type"] == "image"]
if not image_service:
raise Exception("Could not find image service in catalog")
image_service = image_service[0]
image_api = [
e["url"] for e in image_service["endpoints"]
if e["interface"] == interface and e["region_id"] == region
]
if not image_api:
raise Exception("Could not find required endpoint for image service")
image_api = image_api[0].rstrip("/")
if not image_api.endswith("/v2"):
image_api += "/v2"
# use scoped token to make request to image service endpoint
# list available images
images_resp = requests.get(
f"{image_api}/images",
verify=VERIFY,
headers={"X-Auth-Token": scoped_token},
)
print(images_resp.text)
openstacksdk
and then, the example using openstacksdk - a dedicated Python API library for working with OpenStack clouds. Note that with properly set up clouds.yaml configuration file that could've been just 3 lines of code:
#!/usr/bin/env python
import os
import openstack
fed_auth = {
"os_auth_type": "v3oidcpassword",
"os_discovery_endpoint": os.getenv("OS_DISCOVERY_ENDPOINT"),
"os_identity_provider": os.getenv("OS_IDENTITY_PROVIDER"),
"os_protocol": os.getenv("OS_PROTOCOL"),
"os_openid_scope": os.getenv("OS_OPENID_SCOPE"),
"os_client_secret": os.getenv("OS_CLIENT_SECRET"),
"os_client_id": os.getenv("OS_CLIENT_ID"),
"os_username": os.getenv("OS_USERNAME"),
"os_password": os.getenv("OS_PASSWORD"),
"os_project_domain_id": os.getenv("OS_PROJECT_DOMAIN_ID"),
"os_project_name": os.getenv("OS_PROJECT_NAME"),
"os_auth_url": os.getenv("OS_AUTH_URL"),
"os_region_name": os.getenv("OS_REGION_NAME"),
"os_interface": os.getenv("OS_INTERFACE"),
"os_insecure": True,
}
fed = openstack.connect(load_yaml_config=False, **fed_auth)
# if you save the auth and access info to a clouds.yaml file,
# https://docs.openstack.org/openstacksdk/latest/user/config/configuration.html#config-files
# then you don't need env variables, and all the code above can be replaced
# with single call
#
# fed = openstack.connect(cloud=<cloud name>)
print(fed.list_images())