Skip to content

Commit cbdd16b

Browse files
committed
feat: add keystoneauth_kubeservicetoken authentication plugin
1 parent b4e211d commit cbdd16b

6 files changed

Lines changed: 724 additions & 0 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# File-backed OIDC access token plugin
2+
3+
This package provides a `keystoneauth1` plugin that extends the OIDC
4+
access-token flow by reading the OIDC access token from a file
5+
(`access_token_file`) at authentication and reauthentication time.
6+
7+
## Auth type
8+
9+
- `v3oidcaccesstokenfile`
10+
11+
## Required options
12+
13+
- `auth_url`
14+
- `identity_provider`
15+
- `protocol`
16+
- `access_token_file`
17+
18+
## Service configuration examples
19+
20+
### Nova (`nova.conf`)
21+
22+
```ini
23+
[service_user]
24+
auth_type = v3oidcaccesstokenfile
25+
auth_url = https://keystone.example/v3
26+
identity_provider = k8s-workload-idp
27+
protocol = openid
28+
access_token_file = /var/run/secrets/openstack/nova-oidc-token
29+
send_service_user_token = true
30+
```
31+
32+
### Ironic -> Neutron client (`ironic.conf`)
33+
34+
```ini
35+
[neutron]
36+
auth_type = v3oidcaccesstokenfile
37+
auth_url = https://keystone.internal:5000/v3
38+
identity_provider = k8s-workload-idp
39+
protocol = openid
40+
access_token_file = /var/run/secrets/openstack/ironic-oidc-token
41+
region_name = RegionOne
42+
```
43+
44+
### Neutron -> Placement client (`neutron.conf`)
45+
46+
```ini
47+
[placement]
48+
auth_type = v3oidcaccesstokenfile
49+
auth_url = https://keystone.example/v3
50+
identity_provider = k8s-workload-idp
51+
protocol = openid
52+
access_token_file = /var/run/secrets/openstack/neutron-placement-oidc-token
53+
valid_interfaces = internal
54+
```
55+
56+
## Behavior notes
57+
58+
- Token content is read from file on authentication and reauthentication.
59+
- Whitespace in the token file is trimmed.
60+
- Missing, unreadable, or empty token files fail with an explicit auth error.
61+
- Keystone token caching is preserved; token file updates are consumed when
62+
keystoneauth reauthenticates near Keystone token expiry.
63+
64+
## Rollout notes
65+
66+
1. Install this package where the OpenStack service runs.
67+
2. Configure the service to use `auth_type = v3oidcaccesstokenfile`.
68+
3. Set `access_token_file` to the rotated token file path. This will usually be
69+
path to where the Kubernetes secret is mounted.
70+
4. Optionally verify at least one full Keystone token renewal cycle to confirm
71+
file updates are consumed.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
[build-system]
2+
requires = ["setuptools>=68", "wheel"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "keystoneauth-kubeservicetoken"
7+
version = "0.1.0"
8+
description = "Keystoneauth plugin that reads OIDC access tokens from a file"
9+
readme = "README.md"
10+
requires-python = ">=3.10"
11+
dependencies = [
12+
"keystoneauth1>=5.0.0",
13+
]
14+
15+
[dependency-groups]
16+
dev = [
17+
"pytest>=8.0.0",
18+
]
19+
20+
[project.entry-points."keystoneauth1.plugin"]
21+
v3oidcaccesstokenfile = "keystoneauth_kubeservicetoken.oidc:OpenIDConnectAccessTokenFileLoader"
22+
23+
[tool.setuptools]
24+
package-dir = {"" = "src"}
25+
26+
[tool.setuptools.packages.find]
27+
where = ["src"]
28+
29+
[tool.pytest.ini_options]
30+
testpaths = ["tests"]
31+
32+
[tool.ruff]
33+
line-length = 88
34+
35+
[tool.ruff.lint]
36+
select = ["E", "F", "B", "I", "UP", "S"]
37+
38+
[tool.ruff.lint.per-file-ignores]
39+
"tests/*.py" = ["S101", "S106"]
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""keystoneauth_kubeservicetoken package."""
2+
3+
from keystoneauth_kubeservicetoken.oidc import (
4+
OpenIDConnectAccessTokenFile,
5+
OpenIDConnectAccessTokenFileLoader,
6+
)
7+
8+
__all__ = [
9+
"OpenIDConnectAccessTokenFile",
10+
"OpenIDConnectAccessTokenFileLoader",
11+
]
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""File-backed OIDC access token plugin for keystoneauth."""
2+
3+
from __future__ import annotations
4+
5+
from keystoneauth1 import exceptions, loading
6+
from keystoneauth1.identity.v3 import oidc
7+
from keystoneauth1.loading._plugins.identity import v3 as identity_v3_loading
8+
9+
10+
class OpenIDConnectAccessTokenFile(oidc.OidcAccessToken):
11+
"""OIDC access-token auth plugin that reads the token from a file."""
12+
13+
def __init__(
14+
self, *args, access_token_file: str, access_token: str | None = None, **kwargs
15+
):
16+
if not access_token_file:
17+
raise exceptions.OptionError("'access_token_file' is required")
18+
19+
self.access_token_file = access_token_file
20+
21+
super().__init__(*args, access_token=access_token or "", **kwargs)
22+
23+
def _read_access_token(self) -> str:
24+
try:
25+
with open(self.access_token_file, encoding="utf-8") as token_file:
26+
token = token_file.read().strip()
27+
except FileNotFoundError as exc:
28+
msg = f"OIDC access token file does not exist: {self.access_token_file}"
29+
raise exceptions.AuthorizationFailure(msg) from exc
30+
except OSError as exc:
31+
msg = (
32+
"Unable to read OIDC access token file "
33+
f"'{self.access_token_file}': {exc}"
34+
)
35+
raise exceptions.AuthorizationFailure(msg) from exc
36+
37+
if not token:
38+
msg = f"OIDC access token file is empty: {self.access_token_file}"
39+
raise exceptions.AuthorizationFailure(msg)
40+
41+
return token
42+
43+
def get_unscoped_auth_ref(self, session):
44+
self.access_token = self._read_access_token()
45+
return super().get_unscoped_auth_ref(session)
46+
47+
48+
class OpenIDConnectAccessTokenFileLoader(identity_v3_loading.OpenIDConnectAccessToken):
49+
"""Loader for the file-backed OIDC access-token auth plugin."""
50+
51+
@property
52+
def plugin_class(self):
53+
return OpenIDConnectAccessTokenFile
54+
55+
def get_options(self):
56+
options = [
57+
option for option in super().get_options() if option.dest != "access_token"
58+
]
59+
60+
options.append(
61+
loading.Opt(
62+
"access-token-file",
63+
required=True,
64+
help="Path to a file containing the OIDC access token.",
65+
)
66+
)
67+
return options
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
from unittest.mock import patch
5+
6+
import pytest
7+
from keystoneauth1 import exceptions, loading
8+
from keystoneauth1.identity.v3 import oidc as upstream_oidc
9+
10+
from keystoneauth_kubeservicetoken.oidc import (
11+
OpenIDConnectAccessTokenFile,
12+
OpenIDConnectAccessTokenFileLoader,
13+
)
14+
15+
16+
class FakeAuthRef:
17+
def __init__(self, auth_token: str, expires_soon: bool):
18+
self.auth_token = auth_token
19+
self._expires_soon = expires_soon
20+
21+
def will_expire_soon(self, _stale_duration: int) -> bool:
22+
return self._expires_soon
23+
24+
25+
def _create_plugin(token_file: Path) -> OpenIDConnectAccessTokenFile:
26+
return OpenIDConnectAccessTokenFile(
27+
auth_url="https://keystone.example/v3",
28+
identity_provider="example-idp",
29+
protocol="openid",
30+
access_token_file=str(token_file),
31+
)
32+
33+
34+
@pytest.fixture
35+
def auth_options() -> dict[str, str]:
36+
return {
37+
"auth_url": "https://keystone.example/v3",
38+
"identity_provider": "example-idp",
39+
"protocol": "openid",
40+
}
41+
42+
43+
def test_loader_options_require_access_token_file_and_not_access_token():
44+
loader = OpenIDConnectAccessTokenFileLoader()
45+
46+
options = {option.dest: option for option in loader.get_options()}
47+
48+
assert "access_token_file" in options
49+
assert options["access_token_file"].required
50+
assert "access_token" not in options
51+
52+
53+
def test_loader_can_initialize_plugin_with_access_token_file_only(
54+
tmp_path, auth_options
55+
):
56+
token_file = tmp_path / "token"
57+
token_file.write_text("oidc-token", encoding="utf-8")
58+
59+
loader = OpenIDConnectAccessTokenFileLoader()
60+
plugin = loader.load_from_options(
61+
**auth_options,
62+
access_token_file=str(token_file),
63+
)
64+
65+
assert isinstance(plugin, OpenIDConnectAccessTokenFile)
66+
assert plugin.access_token_file == str(token_file)
67+
68+
69+
def test_plugin_loader_is_discoverable_by_auth_type():
70+
loader = loading.get_plugin_loader("v3oidcaccesstokenfile")
71+
72+
assert isinstance(loader, OpenIDConnectAccessTokenFileLoader)
73+
74+
75+
def test_missing_access_token_file_configuration_fails(auth_options):
76+
with pytest.raises(exceptions.OptionError, match="access_token_file"):
77+
OpenIDConnectAccessTokenFile(
78+
**auth_options,
79+
access_token_file="",
80+
)
81+
82+
83+
def test_auth_reads_trimmed_token_from_file_each_time(tmp_path):
84+
token_file = tmp_path / "token"
85+
token_file.write_text(" token-a\n", encoding="utf-8")
86+
87+
plugin = _create_plugin(token_file)
88+
observed_tokens: list[str] = []
89+
90+
def fake_super_get_unscoped_auth_ref(self, _session):
91+
observed_tokens.append(self.access_token)
92+
return object()
93+
94+
with patch.object(
95+
upstream_oidc.OidcAccessToken,
96+
"get_unscoped_auth_ref",
97+
autospec=True,
98+
side_effect=fake_super_get_unscoped_auth_ref,
99+
):
100+
plugin.get_unscoped_auth_ref(session=None)
101+
token_file.write_text("token-b\n", encoding="utf-8")
102+
plugin.get_unscoped_auth_ref(session=None)
103+
104+
assert observed_tokens == ["token-a", "token-b"]
105+
106+
107+
def test_auth_fails_for_missing_file(tmp_path):
108+
plugin = _create_plugin(tmp_path / "missing-token")
109+
110+
with pytest.raises(exceptions.AuthorizationFailure, match="does not exist"):
111+
plugin._read_access_token()
112+
113+
114+
def test_auth_fails_for_unreadable_file(tmp_path):
115+
token_file = tmp_path / "token"
116+
token_file.write_text("token", encoding="utf-8")
117+
plugin = _create_plugin(token_file)
118+
119+
with patch("builtins.open", side_effect=OSError("permission denied")):
120+
with pytest.raises(exceptions.AuthorizationFailure, match="Unable to read"):
121+
plugin._read_access_token()
122+
123+
124+
def test_auth_fails_for_empty_file(tmp_path):
125+
token_file = tmp_path / "token"
126+
token_file.write_text("\n\t", encoding="utf-8")
127+
plugin = _create_plugin(token_file)
128+
129+
with pytest.raises(exceptions.AuthorizationFailure, match="is empty"):
130+
plugin._read_access_token()
131+
132+
133+
def test_file_updates_consumed_on_reauth_not_every_request(tmp_path):
134+
token_file = tmp_path / "token"
135+
token_file.write_text("token-a", encoding="utf-8")
136+
plugin = _create_plugin(token_file)
137+
138+
plugin.auth_ref = FakeAuthRef(
139+
auth_token="cached-keystone-token", expires_soon=False
140+
)
141+
142+
def fake_get_auth_ref(_session):
143+
plugin.get_unscoped_auth_ref(session=None)
144+
return FakeAuthRef(auth_token=f"ks-{plugin.access_token}", expires_soon=False)
145+
146+
with (
147+
patch.object(
148+
upstream_oidc.OidcAccessToken,
149+
"get_unscoped_auth_ref",
150+
autospec=True,
151+
return_value=object(),
152+
),
153+
patch.object(plugin, "get_auth_ref", side_effect=fake_get_auth_ref),
154+
):
155+
token_file.write_text("token-b", encoding="utf-8")
156+
157+
assert plugin.get_token(session=None) == "cached-keystone-token"
158+
159+
plugin.auth_ref = FakeAuthRef(auth_token="expiring-token", expires_soon=True)
160+
assert plugin.get_token(session=None) == "ks-token-b"

0 commit comments

Comments
 (0)