Skip to content

Commit 38926a4

Browse files
committed
Improve Model Target auth fidelity
1 parent 899ce31 commit 38926a4

7 files changed

Lines changed: 210 additions & 61 deletions

File tree

docs/source/differences-to-vws.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,8 @@ Model Target datasets
115115

116116
The Model Target Web API mock supports OAuth2 token requests, standard and advanced dataset creation, status polling, dataset downloads, and deletion.
117117
The generated dataset download is a small valid zip file containing request metadata, not a real Vuforia Engine Model Target dataset.
118-
Model Target API routes accept any non-empty bearer token.
118+
Model Target API routes require a syntactically JSON Web Token-shaped bearer token, such as the token returned by the mock OAuth2 route.
119+
The mock does not verify token signatures, claims, expiry, or revocation.
119120

120121
Header cases
121122
------------

newsfragments/3192.change

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Improve Model Target Web API mock authentication failure responses.

src/mock_vws/_flask_server/vws.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ def _flask_request_data() -> RequestData:
136136
method=request.method,
137137
path=request.path,
138138
headers=dict(request.headers),
139-
body=request.data,
139+
body=request.get_data(parse_form_data=False),
140140
)
141141

142142

src/mock_vws/_model_target_web_api.py

Lines changed: 104 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616

1717
_ResponseType = tuple[int, dict[str, str], str | bytes]
1818
_MAX_ADVANCED_MODEL_COUNT = 20
19+
_JWT_DOT_COUNT = 2
20+
_MOCK_MODEL_TARGET_CLIENT_ID = "client-id"
21+
_MOCK_MODEL_TARGET_CLIENT_SECRET = "client-secret" # noqa: S105
1922

2023

2124
@beartype
@@ -57,6 +60,16 @@ def _error_response(
5760
)
5861

5962

63+
@beartype
64+
def _oauth2_error_response(
65+
*,
66+
status_code: HTTPStatus,
67+
body: dict[str, str],
68+
) -> _ResponseType:
69+
"""Return an OAuth2 error response."""
70+
return _json_response(status_code=status_code, body=body)
71+
72+
6073
@beartype
6174
def _get_header(request: RequestData, name: str) -> str | None:
6275
"""Return a request header, case-insensitively."""
@@ -67,6 +80,28 @@ def _get_header(request: RequestData, name: str) -> str | None:
6780
return None
6881

6982

83+
@beartype
84+
def _basic_auth_credentials(auth_header: str | None) -> tuple[str, str] | None:
85+
"""Return HTTP Basic credentials from an authorization header."""
86+
if auth_header is None or not auth_header.startswith("Basic "):
87+
return None
88+
89+
encoded_credentials = auth_header.removeprefix("Basic ").strip()
90+
try:
91+
decoded_credentials = base64.b64decode(
92+
s=encoded_credentials,
93+
validate=True,
94+
).decode(encoding="utf-8")
95+
except ValueError:
96+
return None
97+
98+
client_id, separator, client_secret = decoded_credentials.partition(":")
99+
if not separator:
100+
return None
101+
102+
return client_id, client_secret
103+
104+
70105
@beartype
71106
def _require_bearer_token(request: RequestData) -> _ResponseType | None:
72107
"""Return an error response if the request has no bearer token."""
@@ -78,47 +113,95 @@ def _require_bearer_token(request: RequestData) -> _ResponseType | None:
78113
message="no Bearer token",
79114
target="jwt",
80115
)
81-
if not auth_header.removeprefix("Bearer ").strip():
116+
bearer_token = auth_header.removeprefix("Bearer ").strip()
117+
if not bearer_token:
118+
return _error_response(
119+
status_code=HTTPStatus.UNAUTHORIZED,
120+
code="401",
121+
message="no Bearer token",
122+
target="jwt",
123+
)
124+
if bearer_token.count(".") != _JWT_DOT_COUNT:
82125
return _error_response(
83126
status_code=HTTPStatus.UNAUTHORIZED,
84127
code="401",
85-
message="invalid Bearer token",
128+
message="Invalid JWT serialization: Missing dot delimiter(s)",
86129
target="jwt",
87130
)
88131
return None
89132

90133

134+
@beartype
135+
def _fake_jwt(*, token_source: bytes) -> str:
136+
"""Return a deterministic bearer token for the mock."""
137+
138+
def encode_part(value: dict[str, Any]) -> str:
139+
"""Return a base64url-encoded token part."""
140+
raw_part = json.dumps(
141+
obj=value,
142+
sort_keys=True,
143+
separators=(",", ":"),
144+
).encode(encoding="utf-8")
145+
return (
146+
base64.urlsafe_b64encode(s=raw_part)
147+
.decode(
148+
encoding="ascii",
149+
)
150+
.rstrip("=")
151+
)
152+
153+
header = encode_part(value={"alg": "mock", "typ": "JWT"})
154+
payload = encode_part(
155+
value={
156+
"aud": "vuforia-model-target",
157+
"src": base64.urlsafe_b64encode(s=token_source)
158+
.decode(
159+
encoding="ascii",
160+
)
161+
.rstrip("="),
162+
},
163+
)
164+
return f"{header}.{payload}.mock-signature"
165+
166+
91167
@beartype
92168
def oauth2_token(request: RequestData) -> _ResponseType:
93169
"""Return a fake OAuth2 access token."""
94170
auth_header = _get_header(request=request, name="Authorization")
95171
form = parse_qs(qs=request.body.decode(encoding="utf-8"))
96-
grant_type = form.get("grant_type", [""])[0]
97-
has_basic_auth = auth_header is not None and auth_header.startswith(
98-
"Basic ",
99-
)
100-
has_password_credentials = all(
101-
form.get(field, [""])[0] for field in ("username", "password")
102-
)
103-
if grant_type not in {"", "client_credentials", "password"} or (
104-
not has_basic_auth and not has_password_credentials
105-
):
106-
return _error_response(
172+
grant_type = form.get("grant_type", ["client_credentials"])[0]
173+
if grant_type != "client_credentials":
174+
return _oauth2_error_response(
107175
status_code=HTTPStatus.BAD_REQUEST,
108-
code="BAD_REQUEST",
109-
message="Invalid OAuth2 token request.",
110-
target="grant_type",
176+
body={"error": "unsupported_grant_type"},
177+
)
178+
179+
basic_credentials = _basic_auth_credentials(auth_header=auth_header)
180+
if basic_credentials is None:
181+
return _oauth2_error_response(
182+
status_code=HTTPStatus.UNAUTHORIZED,
183+
body={
184+
"error": "invalid_request",
185+
"error_description": (
186+
"Missing or invalid authorization header"
187+
),
188+
},
189+
)
190+
191+
if basic_credentials != (
192+
_MOCK_MODEL_TARGET_CLIENT_ID,
193+
_MOCK_MODEL_TARGET_CLIENT_SECRET,
194+
):
195+
return _oauth2_error_response(
196+
status_code=HTTPStatus.UNAUTHORIZED,
197+
body={"error": "invalid_client"},
111198
)
112199

113200
token_source = request.body or (auth_header or "").encode()
114-
access_token = base64.urlsafe_b64encode(s=token_source).decode(
115-
encoding="ascii",
116-
)
117-
access_token = access_token.rstrip("=") or "mock-vuforia-access-token"
118201
return _json_response(
119202
status_code=HTTPStatus.OK,
120203
body={
121-
"access_token": access_token,
204+
"access_token": _fake_jwt(token_source=token_source),
122205
"token_type": "bearer",
123206
"expires_in": 3600,
124207
},

tests/mock_vws/test_model_target_web_api.py

Lines changed: 98 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
_VWS_HOST = "https://vws.vuforia.com"
1919
_DATASET_UUID = "0b12466eee5d49409a440927006ff5d8"
20+
_MOCK_BEARER_TOKEN = "mock.header.signature"
2021

2122

2223
def _dataset_request(*, cad_data_url: str) -> dict[str, Any]:
@@ -114,6 +115,17 @@ def _assert_model_target_error(
114115
}
115116

116117

118+
def _assert_oauth2_error(
119+
*,
120+
response: requests.Response,
121+
status_code: HTTPStatus,
122+
body: dict[str, str],
123+
) -> None:
124+
"""Assert an OAuth2 error response."""
125+
assert response.status_code == status_code
126+
assert response.json() == body
127+
128+
117129
@pytest.mark.usefixtures("verify_model_target_mock_vuforia")
118130
class TestAuthentication:
119131
"""Tests for Model Target Web API authentication."""
@@ -195,46 +207,95 @@ def test_missing_bearer_token(
195207
},
196208
}
197209

198-
199-
class TestMockErrors:
200-
"""Tests for mock-only Model Target Web API error paths."""
201-
202210
@staticmethod
203-
def test_invalid_oauth2_token_request() -> None:
204-
"""Invalid OAuth2 token requests are rejected."""
205-
with MockVWS():
206-
response = requests.post(
207-
url=f"{_VWS_HOST}/oauth2/token",
208-
data={"grant_type": "unsupported"},
209-
timeout=30,
210-
)
211+
@pytest.mark.parametrize(
212+
argnames=("authorization", "message"),
213+
argvalues=[
214+
pytest.param("Bearer ", "no Bearer token", id="blank"),
215+
pytest.param(
216+
"Bearer invalid-token",
217+
"Invalid JWT serialization: Missing dot delimiter(s)",
218+
id="malformed",
219+
),
220+
],
221+
)
222+
def test_invalid_bearer_token(
223+
*,
224+
authorization: str,
225+
message: str,
226+
) -> None:
227+
"""Invalid bearer tokens are rejected."""
228+
response = requests.get(
229+
url=f"{_VWS_HOST}/modeltargets/datasets/{_DATASET_UUID}/status",
230+
headers={"Authorization": authorization},
231+
timeout=30,
232+
)
211233

212234
_assert_model_target_error(
213235
response=response,
214-
status_code=HTTPStatus.BAD_REQUEST,
215-
code="BAD_REQUEST",
216-
message="Invalid OAuth2 token request.",
217-
target="grant_type",
236+
status_code=HTTPStatus.UNAUTHORIZED,
237+
code="401",
238+
message=message,
239+
target="jwt",
218240
)
219241

220242
@staticmethod
221-
def test_blank_bearer_token() -> None:
222-
"""A blank bearer token is rejected."""
223-
with MockVWS():
224-
response = requests.get(
225-
url=f"{_VWS_HOST}/modeltargets/datasets/{_DATASET_UUID}/status",
226-
headers={"Authorization": "Bearer "},
227-
timeout=30,
228-
)
243+
@pytest.mark.parametrize(
244+
argnames=("auth", "data", "status_code", "body"),
245+
argvalues=[
246+
pytest.param(
247+
None,
248+
{"grant_type": "client_credentials"},
249+
HTTPStatus.UNAUTHORIZED,
250+
{
251+
"error": "invalid_request",
252+
"error_description": (
253+
"Missing or invalid authorization header"
254+
),
255+
},
256+
id="missing-basic-auth",
257+
),
258+
pytest.param(
259+
("invalid-client-id", "invalid-client-secret"),
260+
{"grant_type": "client_credentials"},
261+
HTTPStatus.UNAUTHORIZED,
262+
{"error": "invalid_client"},
263+
id="invalid-client",
264+
),
265+
pytest.param(
266+
("invalid-client-id", "invalid-client-secret"),
267+
{"grant_type": "unsupported"},
268+
HTTPStatus.BAD_REQUEST,
269+
{"error": "unsupported_grant_type"},
270+
id="unsupported-grant-type",
271+
),
272+
],
273+
)
274+
def test_invalid_oauth2_token_request(
275+
*,
276+
auth: tuple[str, str] | None,
277+
data: dict[str, str],
278+
status_code: HTTPStatus,
279+
body: dict[str, str],
280+
) -> None:
281+
"""Invalid OAuth2 token requests are rejected."""
282+
response = requests.post(
283+
url=f"{_VWS_HOST}/oauth2/token",
284+
auth=auth,
285+
data=data,
286+
timeout=30,
287+
)
229288

230-
_assert_model_target_error(
289+
_assert_oauth2_error(
231290
response=response,
232-
status_code=HTTPStatus.UNAUTHORIZED,
233-
code="401",
234-
message="invalid Bearer token",
235-
target="jwt",
291+
status_code=status_code,
292+
body=body,
236293
)
237294

295+
296+
class TestMockErrors:
297+
"""Tests for mock-only Model Target Web API error paths."""
298+
238299
@staticmethod
239300
@pytest.mark.parametrize(
240301
argnames=("body", "headers", "message", "target"),
@@ -266,7 +327,10 @@ def test_invalid_request_body(
266327
with MockVWS():
267328
response = requests.post(
268329
url=f"{_VWS_HOST}/modeltargets/datasets",
269-
headers={"Authorization": "Bearer token", **headers},
330+
headers={
331+
"Authorization": f"Bearer {_MOCK_BEARER_TOKEN}",
332+
**headers,
333+
},
270334
data=body,
271335
timeout=30,
272336
)
@@ -337,7 +401,7 @@ def test_invalid_dataset_request(
337401
with MockVWS():
338402
response = requests.post(
339403
url=f"{_VWS_HOST}{path}",
340-
headers={"Authorization": "Bearer token"},
404+
headers={"Authorization": f"Bearer {_MOCK_BEARER_TOKEN}"},
341405
json=body,
342406
timeout=30,
343407
)
@@ -381,7 +445,7 @@ def test_unknown_dataset(
381445
response = requests.request(
382446
method=method,
383447
url=f"{_VWS_HOST}{path}",
384-
headers={"Authorization": "Bearer token"},
448+
headers={"Authorization": f"Bearer {_MOCK_BEARER_TOKEN}"},
385449
timeout=30,
386450
)
387451

@@ -399,7 +463,7 @@ def test_processing_dataset_cannot_be_downloaded() -> None:
399463
with MockVWS(processing_time_seconds=60):
400464
create_response = requests.post(
401465
url=f"{_VWS_HOST}/modeltargets/datasets",
402-
headers={"Authorization": "Bearer token"},
466+
headers={"Authorization": f"Bearer {_MOCK_BEARER_TOKEN}"},
403467
json=_UNAUTHENTICATED_DATASET_REQUEST,
404468
timeout=30,
405469
)
@@ -408,7 +472,7 @@ def test_processing_dataset_cannot_be_downloaded() -> None:
408472
f"{_VWS_HOST}/modeltargets/datasets/"
409473
f"{create_response.json()['uuid']}/dataset"
410474
),
411-
headers={"Authorization": "Bearer token"},
475+
headers={"Authorization": f"Bearer {_MOCK_BEARER_TOKEN}"},
412476
timeout=30,
413477
)
414478

0 commit comments

Comments
 (0)