Skip to content

Commit fe15df6

Browse files
authored
Merge pull request #1257 from lsst-sqre/tickets/DM-49329
DM-49329: Always authorize OPTIONS requests
2 parents b87aa0d + f227799 commit fe15df6

File tree

7 files changed

+74
-6
lines changed

7 files changed

+74
-6
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
### Backwards-incompatible changes
2+
3+
- Always authorize `OPTIONS` requests rather than expecting them to contain authentication credentials. This is required for proper CORS handling since the CORS pre-flight specification says that credentials are not included. Gafaelfawr previously effectively blocked all `OPTIONS` requests by replying with 302 or 401.

src/gafaelfawr/handlers/ingress.py

+26-3
Original file line numberDiff line numberDiff line change
@@ -299,9 +299,18 @@ async def authenticate_with_type(
299299
examples=["basic"],
300300
),
301301
] = AuthType.Bearer,
302+
x_original_method: Annotated[str, Header()],
302303
context: Annotated[RequestContext, Depends(context_dependency)],
303-
) -> TokenData:
304-
"""Set authentication challenge based on auth_type parameter."""
304+
) -> TokenData | None:
305+
"""Handle ``OPTIONS`` and user authentication.
306+
307+
If the request method is ``OPTIONS``, no authentication information will
308+
be included. Return `None` in that case. Otherwise, authenticate the user
309+
or, if they are not authenticated, send an authentication challenge based
310+
on the ``auth_type`` parameter.
311+
"""
312+
if x_original_method == "OPTIONS":
313+
return None
305314
authenticate = AuthenticateRead(
306315
allow_cookies=allow_cookies, auth_type=auth_type, ajax_forbidden=True
307316
)
@@ -321,11 +330,25 @@ async def authenticate_with_type(
321330
)
322331
async def get_auth(
323332
*,
333+
x_original_method: Annotated[str, Header()],
324334
auth_config: Annotated[AuthConfig, Depends(auth_config)],
325-
token_data: Annotated[TokenData, Depends(authenticate_with_type)],
335+
token_data: Annotated[TokenData | None, Depends(authenticate_with_type)],
326336
context: Annotated[RequestContext, Depends(context_dependency)],
327337
response: Response,
328338
) -> dict[str, str]:
339+
# If token_data is None, this is an OPTIONS requests. Those requests are
340+
# treated identically to anonymous requests: we strip any authentication
341+
# credentials (which should not be included anyway) and always return
342+
# success. We cannot really do anything else, since the standard says CORS
343+
# pre-flight checks should not include credentials.
344+
#
345+
# Redo the verification that the method really is OPTIONS out of paranoia.
346+
if not token_data:
347+
if x_original_method != "OPTIONS":
348+
raise RuntimeError("Unauthenticated request to /ingress/auth")
349+
return await get_anonymous(context=context, response=response)
350+
351+
# Do some basic checks and analysis of the authentication data.
329352
check_lifetime(context, auth_config, token_data)
330353
token_scopes = set(token_data.scopes)
331354

src/gafaelfawr/services/kubernetes.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ def _build_annotations(self, ingress: GafaelfawrIngress) -> dict[str, str]:
128128
}
129129
if ingress.config.auth_cache_duration:
130130
annotations["nginx.ingress.kubernetes.io/auth-cache-key"] = (
131-
"$http_cookie$http_authorization"
131+
"$request_method$http_cookie$http_authorization"
132132
)
133133
annotations["nginx.ingress.kubernetes.io/auth-cache-duration"] = (
134134
f"200 202 401 {ingress.config.auth_cache_duration}"

tests/conftest.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,10 @@ async def client(app: FastAPI) -> AsyncIterator[AsyncClient]:
7474
"""Return an ``httpx.AsyncClient`` configured to talk to the test app."""
7575
async with AsyncClient(
7676
base_url=f"https://{TEST_HOSTNAME}",
77-
headers={"X-Original-URL": "https://foo.example.com/bar"},
77+
headers={
78+
"X-Original-Method": "GET",
79+
"X-Original-URL": "https://foo.example.com/bar",
80+
},
7881
transport=ASGITransport(app=app),
7982
) as client:
8083
yield client

tests/data/kubernetes/output/ingresses.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ metadata:
4545
namespace: {namespace}
4646
annotations:
4747
another.annotation.example.com: bar
48-
nginx.ingress.kubernetes.io/auth-cache-key: "$http_cookie$http_authorization"
48+
nginx.ingress.kubernetes.io/auth-cache-key: "$request_method$http_cookie$http_authorization"
4949
nginx.ingress.kubernetes.io/auth-cache-duration: "200 202 401 5m"
5050
nginx.ingress.kubernetes.io/auth-method: "GET"
5151
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization,Cookie,X-Auth-Request-Email,X-Auth-Request-Service,X-Auth-Request-Token,X-Auth-Request-User"

tests/handlers/ingress_test.py

+37
Original file line numberDiff line numberDiff line change
@@ -1229,3 +1229,40 @@ async def test_user_domain(client: AsyncClient, factory: Factory) -> None:
12291229
assert isinstance(authenticate, AuthErrorChallenge)
12301230
assert authenticate.auth_type == AuthType.Bearer
12311231
assert authenticate.error == AuthError.insufficient_scope
1232+
1233+
1234+
@pytest.mark.asyncio
1235+
async def test_options(client: AsyncClient, factory: Factory) -> None:
1236+
"""Test special handling of OPTIONS."""
1237+
token_data = await create_session_token(
1238+
factory, group_names=["admin"], scopes={"read:all"}
1239+
)
1240+
1241+
# An OPTIONS request should always be allowed, even if there are no
1242+
# credentials provided at all and scopes are required.
1243+
r = await client.get(
1244+
"/ingress/auth",
1245+
params={"scope": "read:all"},
1246+
headers={
1247+
"X-Original-Method": "OPTIONS",
1248+
"X-Original-URL": "https://example.com/foo",
1249+
},
1250+
)
1251+
assert r.status_code == 200
1252+
1253+
# If credentials are included, they should be stripped.
1254+
await set_session_cookie(client, token_data.token)
1255+
client.cookies.set("_other", "somevalue", domain=TEST_HOSTNAME)
1256+
r = await client.get(
1257+
"/ingress/auth",
1258+
params={"scope": "read:all"},
1259+
headers=[
1260+
("Authorization", "token some-other-token"),
1261+
("Authorization", f"bearer {Token()}"),
1262+
("X-Original-Method", "OPTIONS"),
1263+
("X-Original-URL", "https://example.com/foo"),
1264+
],
1265+
)
1266+
assert r.status_code == 200
1267+
assert r.headers["Authorization"] == "token some-other-token"
1268+
assert r.headers["Cookie"] == "_other=somevalue"

tests/selenium/tokens_test.py

+2
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ async def test_token_info(
5757
params={"scope": "exec:test", "notebook": "true"},
5858
headers={
5959
"Cookie": f"{COOKIE_NAME}={cookie}",
60+
"X-Original-Method": "GET",
6061
"X-Original-URL": "https://foo.example.com/bar",
6162
},
6263
)
@@ -67,6 +68,7 @@ async def test_token_info(
6768
params={"scope": "exec:test", "delegate_to": "service"},
6869
headers={
6970
"Cookie": f"{COOKIE_NAME}={cookie}",
71+
"X-Original-Method": "GET",
7072
"X-Original-URL": "https://foo.example.com/bar",
7173
},
7274
)

0 commit comments

Comments
 (0)