Skip to content

Commit c1d985e

Browse files
alfonsosastreAlfonso Sastrecursoragent
authored
feat: add VERSIONS_REQUIRE_AUTH to make versions/details auth optional (#12)
* feat: add VERSIONS_REQUIRE_AUTH to make versions/details auth optional - Add VERSIONS_REQUIRE_AUTH config (default True) to allow unauthenticated access to /versions and /{version}/details for discovery (e.g. Payter) - VersionsAuthorizationVerifier uses optional Authorization header when VERSIONS_REQUIRE_AUTH=false - Add tests for 2.2.1 and 2.3.0 when auth is optional - Set via VERSIONS_REQUIRE_AUTH env var Co-authored-by: Cursor <cursoragent@cursor.com> * fix: use list | str in isinstance for ruff UP038 Co-authored-by: Cursor <cursoragent@cursor.com> * style: apply ruff format to verifier.py Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Alfonso Sastre <alfonso@elumobility.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent b43bf55 commit c1d985e

5 files changed

Lines changed: 229 additions & 14 deletions

File tree

ocpi/core/authentication/verifier.py

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -135,30 +135,31 @@ async def __call__(
135135

136136
class VersionsAuthorizationVerifier(CredentialsAuthorizationVerifier):
137137
"""
138-
A class responsible for verifying authorization tokens
139-
based on the specified version number.
138+
Verifies authorization for versions and version details endpoints.
139+
When VERSIONS_REQUIRE_AUTH is False, allows unauthenticated access (discovery).
140140
"""
141141

142142
async def __call__(
143143
self,
144-
authorization: str = auth_verifier,
144+
authorization: str = Header(default="", alias="Authorization"),
145145
authenticator: Authenticator = Depends(get_authenticator),
146146
) -> str | dict | None:
147147
"""
148-
Verifies the authorization token using the specified version
149-
and an Authenticator for version endpoints.
150-
151-
:param authorization (str): The authorization header containing
152-
the token.
153-
:param authenticator (Authenticator): An Authenticator instance used
154-
for authentication.
155-
156-
:raises AuthorizationOCPIError: If there is an issue with
157-
the authorization token.
148+
Verifies the authorization token for version endpoints.
149+
If VERSIONS_REQUIRE_AUTH is False, allows requests without token.
158150
"""
159151
if settings.NO_AUTH and authorization == "":
160152
logger.debug("Authentication skipped due to NO_AUTH setting.")
161153
return ""
154+
if not settings.VERSIONS_REQUIRE_AUTH and (
155+
not authorization or authorization.strip() == ""
156+
):
157+
logger.debug(
158+
"Versions/details accessed without auth (VERSIONS_REQUIRE_AUTH=false)."
159+
)
160+
return ""
161+
if not authorization or authorization.strip() == "":
162+
return None
162163
return await super().__call__(authorization, authenticator)
163164

164165

ocpi/core/config.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ class Settings(BaseSettings):
1919

2020
ENVIRONMENT: str = "production"
2121
NO_AUTH: bool = False
22+
# When False, versions and version details endpoints can be accessed without auth (discovery).
23+
# Other endpoints still require auth. Set via VERSIONS_REQUIRE_AUTH env var.
24+
VERSIONS_REQUIRE_AUTH: bool = True
2225
PROJECT_NAME: str = "OCPI"
2326
BACKEND_CORS_ORIGINS: list[AnyHttpUrl] = []
2427
OCPI_HOST: str = "www.example.com"
@@ -36,7 +39,7 @@ class Settings(BaseSettings):
3639
def assemble_cors_origins(cls, v: str | list[str]) -> list[str] | str:
3740
if isinstance(v, str) and not v.startswith("["):
3841
return [i.strip() for i in v.split(",")]
39-
if isinstance(v, (list, str)):
42+
if isinstance(v, list | str):
4043
return v
4144
raise ValueError(v)
4245

tests/test_modules/test_v_2_2_1/test_versions/test_versions.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,51 @@ async def do(cls, *args, **kwargs):
104104
)
105105

106106
assert response.status_code == 401
107+
108+
109+
def test_get_versions_without_auth_when_optional(monkeypatch):
110+
"""When VERSIONS_REQUIRE_AUTH=False, /versions works without Authorization header."""
111+
monkeypatch.setattr("ocpi.core.config.settings.VERSIONS_REQUIRE_AUTH", False)
112+
113+
class MockCrud(Crud):
114+
@classmethod
115+
async def do(cls, *args, **kwargs):
116+
return None
117+
118+
app = get_application(
119+
version_numbers=[VersionNumber.v_2_2_1],
120+
roles=[enums.RoleEnum.cpo],
121+
crud=MockCrud,
122+
authenticator=ClientAuthenticator,
123+
modules=[],
124+
)
125+
client = TestClient(app)
126+
127+
response = client.get(VERSIONS_URL)
128+
129+
assert response.status_code == 200
130+
assert len(response.json()["data"]) == 1
131+
132+
133+
def test_get_version_details_without_auth_when_optional(monkeypatch):
134+
"""When VERSIONS_REQUIRE_AUTH=False, /{version}/details works without Authorization header."""
135+
monkeypatch.setattr("ocpi.core.config.settings.VERSIONS_REQUIRE_AUTH", False)
136+
137+
class MockCrud(Crud):
138+
@classmethod
139+
async def do(cls, *args, **kwargs):
140+
return None
141+
142+
app = get_application(
143+
version_numbers=[VersionNumber.v_2_2_1],
144+
roles=[enums.RoleEnum.cpo],
145+
crud=MockCrud,
146+
authenticator=ClientAuthenticator,
147+
modules=[],
148+
)
149+
client = TestClient(app)
150+
151+
response = client.get(VERSION_URL)
152+
153+
assert response.status_code == 200
154+
assert len(response.json()["data"]) == 2
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from tests.test_modules.utils import (
2+
ENCODED_AUTH_TOKEN_V_2_3_0,
3+
ENCODED_RANDOM_AUTH_TOKEN_V_2_3_0,
4+
)
5+
6+
AUTH_HEADERS = {"Authorization": f"Token {ENCODED_AUTH_TOKEN_V_2_3_0}"}
7+
WRONG_AUTH_HEADERS = {"Authorization": f"Token {ENCODED_RANDOM_AUTH_TOKEN_V_2_3_0}"}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
from fastapi.testclient import TestClient
2+
3+
from ocpi.core import enums
4+
from ocpi.core.crud import Crud
5+
from ocpi.main import get_application
6+
from ocpi.modules.versions.enums import VersionNumber
7+
from tests.test_modules.test_v_2_3_0.test_versions.test_utils import (
8+
AUTH_HEADERS,
9+
WRONG_AUTH_HEADERS,
10+
)
11+
from tests.test_modules.utils import AUTH_TOKEN, ClientAuthenticator
12+
13+
VERSIONS_URL = "/ocpi/versions"
14+
VERSION_URL = "/ocpi/2.3.0/details"
15+
16+
17+
def test_get_versions():
18+
class MockCrud(Crud):
19+
@classmethod
20+
async def do(cls, *args, **kwargs):
21+
return AUTH_TOKEN
22+
23+
app = get_application(
24+
version_numbers=[VersionNumber.v_2_3_0],
25+
roles=[enums.RoleEnum.cpo],
26+
crud=MockCrud,
27+
authenticator=ClientAuthenticator,
28+
modules=[],
29+
)
30+
client = TestClient(app)
31+
32+
response = client.get(
33+
VERSIONS_URL,
34+
headers=AUTH_HEADERS,
35+
)
36+
37+
assert response.status_code == 200
38+
assert len(response.json()["data"]) == 1
39+
40+
41+
def test_get_versions_not_authenticated():
42+
class MockCrud(Crud):
43+
@classmethod
44+
async def do(cls, *args, **kwargs):
45+
return None
46+
47+
app = get_application(
48+
version_numbers=[VersionNumber.v_2_3_0],
49+
roles=[enums.RoleEnum.cpo],
50+
crud=MockCrud,
51+
authenticator=ClientAuthenticator,
52+
modules=[],
53+
)
54+
client = TestClient(app)
55+
56+
response = client.get(
57+
VERSIONS_URL,
58+
headers=WRONG_AUTH_HEADERS,
59+
)
60+
61+
assert response.status_code == 401
62+
63+
64+
def test_get_versions_v_2_3_0():
65+
class MockCrud(Crud):
66+
@classmethod
67+
async def do(cls, *args, **kwargs):
68+
return AUTH_TOKEN
69+
70+
app = get_application(
71+
version_numbers=[VersionNumber.v_2_3_0],
72+
roles=[enums.RoleEnum.cpo],
73+
crud=MockCrud,
74+
authenticator=ClientAuthenticator,
75+
modules=[],
76+
)
77+
client = TestClient(app)
78+
79+
response = client.get(
80+
VERSION_URL,
81+
headers=AUTH_HEADERS,
82+
)
83+
84+
assert response.status_code == 200
85+
assert len(response.json()["data"]) == 2
86+
87+
88+
def test_get_versions_v_2_3_0_not_authenticated():
89+
class MockCrud(Crud):
90+
@classmethod
91+
async def do(cls, *args, **kwargs):
92+
return None
93+
94+
app = get_application(
95+
version_numbers=[VersionNumber.v_2_3_0],
96+
roles=[enums.RoleEnum.cpo],
97+
crud=MockCrud,
98+
authenticator=ClientAuthenticator,
99+
modules=[],
100+
)
101+
client = TestClient(app)
102+
103+
response = client.get(
104+
VERSION_URL,
105+
headers=WRONG_AUTH_HEADERS,
106+
)
107+
108+
assert response.status_code == 401
109+
110+
111+
def test_get_versions_without_auth_when_optional(monkeypatch):
112+
"""When VERSIONS_REQUIRE_AUTH=False, /versions works without Authorization header."""
113+
monkeypatch.setattr("ocpi.core.config.settings.VERSIONS_REQUIRE_AUTH", False)
114+
115+
class MockCrud(Crud):
116+
@classmethod
117+
async def do(cls, *args, **kwargs):
118+
return None
119+
120+
app = get_application(
121+
version_numbers=[VersionNumber.v_2_3_0],
122+
roles=[enums.RoleEnum.cpo],
123+
crud=MockCrud,
124+
authenticator=ClientAuthenticator,
125+
modules=[],
126+
)
127+
client = TestClient(app)
128+
129+
response = client.get(VERSIONS_URL)
130+
131+
assert response.status_code == 200
132+
assert len(response.json()["data"]) == 1
133+
134+
135+
def test_get_version_details_without_auth_when_optional(monkeypatch):
136+
"""When VERSIONS_REQUIRE_AUTH=False, /2.3.0/details works without Authorization header."""
137+
monkeypatch.setattr("ocpi.core.config.settings.VERSIONS_REQUIRE_AUTH", False)
138+
139+
class MockCrud(Crud):
140+
@classmethod
141+
async def do(cls, *args, **kwargs):
142+
return None
143+
144+
app = get_application(
145+
version_numbers=[VersionNumber.v_2_3_0],
146+
roles=[enums.RoleEnum.cpo],
147+
crud=MockCrud,
148+
authenticator=ClientAuthenticator,
149+
modules=[],
150+
)
151+
client = TestClient(app)
152+
153+
response = client.get(VERSION_URL)
154+
155+
assert response.status_code == 200
156+
assert len(response.json()["data"]) == 2

0 commit comments

Comments
 (0)