From 0e30d8337318e48f23bdc439df365372bc4b310e Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:30:33 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[MEDIUM]=20?= =?UTF-8?q?Fix=20JWT=20Validation=20Info=20Leak=20and=20Missing=20crit=20V?= =?UTF-8?q?alidation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🚨 Severity: MEDIUM πŸ’‘ Vulnerability: JWT 검증 μ‹œ `crit` 헀더 λˆ„λ½ 및 검증 였λ₯˜ 상세 λ‚΄μ—­ 유좜, ecdsa λ³΄μ•ˆ λ¬Έμ œκ°€ μžˆλŠ” python-jose μ‚¬μš©. 🎯 Impact: κ³΅κ²©μžκ°€ 상세 μ—λŸ¬ λ©”μ‹œμ§€λ₯Ό 톡해 검증 λ‘œμ§μ„ μš°νšŒν•˜κ±°λ‚˜ 탐색할 수 있으며, μ§€μ›ν•˜μ§€ μ•ŠλŠ” μ€‘μš” 헀더λ₯Ό κ°€μ§„ 토큰이 승인될 수 있음. πŸ”§ Fix: `crit` 헀더 길이λ₯Ό κ²€μ¦ν•˜κ³ , λͺ¨λ“  JWT μ—λŸ¬λ₯Ό "invalid token"으둜 ν†΅μΌν•˜μ—¬ 정보 μœ μΆœμ„ 차단함. μ•ˆμ „ν•œ PyJWT둜 라이브러리λ₯Ό λ§ˆμ΄κ·Έλ ˆμ΄μ…˜ν•¨. βœ… Verification: `uv run pytest tests/test_auth_security.py` 싀행을 톡해 λͺ¨λ“  μ˜ˆμ™Έκ°€ "invalid token"을 λ°˜ν™˜ν•˜λŠ”μ§€ 확인 및 `crit` 검증 ν…ŒμŠ€νŠΈ μΆ”κ°€. --- backend/app/auth.py | 51 ++++++++++------- backend/pyproject.toml | 1 - backend/tests/test_auth_security.py | 86 ++++++++++++++++++----------- 3 files changed, 85 insertions(+), 53 deletions(-) diff --git a/backend/app/auth.py b/backend/app/auth.py index d328aa3a7..4c06da9bb 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -9,7 +9,8 @@ import httpx from fastapi import Depends, HTTPException, Request -from jose import jwt +import jwt +from jwt import PyJWK from sqlalchemy import select, delete from sqlalchemy.ext.asyncio import AsyncSession @@ -166,7 +167,7 @@ def _jwt_expiry(claims: dict[str, Any]) -> dt.datetime: exp = claims.get("exp") if not isinstance(exp, int | float): - raise HTTPException(status_code=401, detail="token missing exp") + raise HTTPException(status_code=401, detail="invalid token") return dt.datetime.fromtimestamp(float(exp), tz=dt.timezone.utc) @@ -179,15 +180,26 @@ def _validate_jwt_header(header: dict[str, Any]) -> str: not isinstance(token_type, str) or token_type.strip().lower() not in OIDC_ALLOWED_TOKEN_TYPES ): - raise HTTPException(status_code=401, detail="unsupported token type") + raise HTTPException(status_code=401, detail="invalid token") content_type = header.get("cty") if content_type is not None: - raise HTTPException(status_code=401, detail="unsupported token content type") + raise HTTPException(status_code=401, detail="invalid token") header_alg_raw = header.get("alg") if not isinstance(header_alg_raw, str) or not header_alg_raw: - raise HTTPException(status_code=401, detail="token missing alg") + raise HTTPException(status_code=401, detail="invalid token") + + crit = header.get("crit") + if crit is not None: + if not isinstance(crit, list) or not (1 <= len(crit) <= 10): + raise HTTPException(status_code=401, detail="invalid token") + for item in crit: + if not isinstance(item, str): + raise HTTPException(status_code=401, detail="invalid token") + # Unrecognized critical parameter + raise HTTPException(status_code=401, detail="invalid token") + return header_alg_raw.upper() @@ -240,13 +252,13 @@ async def _decode_verified_oidc_token(token: str) -> dict[str, Any]: try: header = cast(dict[str, Any], jwt.get_unverified_header(token)) except Exception: # noqa: BLE001 - raise HTTPException(status_code=401, detail="invalid token header") + raise HTTPException(status_code=401, detail="invalid token") header_alg = _validate_jwt_header(header) if header_alg not in OIDC_ALLOWED_ALGORITHMS: raise HTTPException( status_code=401, - detail="unsupported token algorithm", + detail="invalid token", ) jwks = await _get_jwks() @@ -255,40 +267,37 @@ async def _decode_verified_oidc_token(token: str) -> dict[str, Any]: jwks = await _get_jwks(force_refresh=True) jwk = _pick_jwk(jwks, header.get("kid")) if jwk is None: - raise HTTPException(status_code=401, detail="unknown signing key") + raise HTTPException(status_code=401, detail="invalid token") kty = jwk.get("kty") if not isinstance(kty, str): - raise HTTPException(status_code=401, detail="algorithm/key type mismatch") + raise HTTPException(status_code=401, detail="invalid token") jwk_kty = kty.upper() if jwk_kty == "RSA": if not (header_alg.startswith("RS") or header_alg.startswith("PS")): - raise HTTPException(status_code=401, detail="algorithm/key type mismatch") + raise HTTPException(status_code=401, detail="invalid token") elif jwk_kty == "EC": if not header_alg.startswith("ES"): - raise HTTPException(status_code=401, detail="algorithm/key type mismatch") + raise HTTPException(status_code=401, detail="invalid token") else: - raise HTTPException(status_code=401, detail="algorithm/key type mismatch") + raise HTTPException(status_code=401, detail="invalid token") try: claims = jwt.decode( token, - jwk, + PyJWK.from_dict(jwk).key, algorithms=list(OIDC_ALLOWED_ALGORITHMS), audience=settings.oidc_audience, issuer=settings.oidc_issuer, options={ "verify_aud": bool(settings.oidc_audience), - "require_aud": bool(settings.oidc_audience), - "require_iss": True, - "require_exp": True, - "require_jti": True, + "require": ["iss", "exp", "jti"] + (["aud"] if settings.oidc_audience else []), "leeway": OIDC_JWT_LEEWAY_SECONDS, }, ) except Exception as err: raise HTTPException( - status_code=401, detail="token verification failed" + status_code=401, detail="invalid token" ) from err return cast(dict[str, Any], claims) @@ -303,13 +312,13 @@ async def _verified_token_from_claims( jwt_id = claims.get("jti") name = claims.get("name") or claims.get("preferred_username") if not isinstance(sub, str): - raise HTTPException(status_code=401, detail="token missing sub") + raise HTTPException(status_code=401, detail="invalid token") if not isinstance(jwt_id, str) or not jwt_id.strip(): - raise HTTPException(status_code=401, detail="token missing jti") + raise HTTPException(status_code=401, detail="invalid token") expires_at = _jwt_expiry(claims) if verify_revocation and await is_token_jti_revoked(jwt_id): - raise HTTPException(status_code=401, detail="token revoked") + raise HTTPException(status_code=401, detail="invalid token") return VerifiedToken( subject=sub, diff --git a/backend/pyproject.toml b/backend/pyproject.toml index b2d47dd2a..b13593786 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -22,7 +22,6 @@ dependencies = [ "alembic>=1.18.5", "cryptography>=50.0.0", "httpx>=0.28.1", - "python-jose[cryptography]>=3.5.0", "redis>=5.0.0", "pyjwt>=2.13.0", "starlette>=1.1.0", diff --git a/backend/tests/test_auth_security.py b/backend/tests/test_auth_security.py index 9f93eb4f1..62e134d55 100644 --- a/backend/tests/test_auth_security.py +++ b/backend/tests/test_auth_security.py @@ -187,7 +187,7 @@ async def test_oidc_rejects_header_selected_algorithm( ) async def fake_jwks() -> dict: - return {"keys": [{"kid": "key-1", "kty": "RSA"}]} + return {"keys": [{"kid": "key-1", "kty": "RSA", "n": "tVKUtcx_n9rt5afY_2WFNvU6PlFMggCrossDi6IGpYGH1xX0IEYf382aB0h4jVOhOaF_sHq1wVlZ1a1gDqF4Axtw89G2Fm_1WkK55Fq5i0Q5t-F4VwBv9lMxt70IIfn9Fj3f4E28z0qZ8a35P6UoMv_x2E2H-2A8-L1A_l4", "e": "AQAB"}]} monkeypatch.setattr(auth, "_get_jwks", fake_jwks) @@ -207,7 +207,7 @@ def fail_decode(*_: object, **__: object) -> dict: ) assert exc_info.value.status_code == 401 - assert exc_info.value.detail == "unsupported token algorithm" + assert exc_info.value.detail == "invalid token" @pytest.mark.asyncio @@ -244,7 +244,7 @@ def fail_decode(*_: object, **__: object) -> dict: await auth._decode_verified_oidc_token("ey...fake...") assert exc_info.value.status_code == 401 - assert exc_info.value.detail == "algorithm/key type mismatch" + assert exc_info.value.detail == "invalid token" @pytest.mark.asyncio @@ -259,7 +259,7 @@ async def test_oidc_decode_uses_fixed_algorithm_allowlist( ) async def fake_jwks() -> dict: - return {"keys": [{"kid": "key-1", "kty": "RSA"}]} + return {"keys": [{"kid": "key-1", "kty": "RSA", "n": "tVKUtcx_n9rt5afY_2WFNvU6PlFMggCrossDi6IGpYGH1xX0IEYf382aB0h4jVOhOaF_sHq1wVlZ1a1gDqF4Axtw89G2Fm_1WkK55Fq5i0Q5t-F4VwBv9lMxt70IIfn9Fj3f4E28z0qZ8a35P6UoMv_x2E2H-2A8-L1A_l4", "e": "AQAB"}]} observed: dict[str, object] = {} @@ -293,10 +293,7 @@ async def mock_is_token_revoked(jti): "issuer": "https://issuer.example", "options": { "verify_aud": True, - "require_aud": True, - "require_iss": True, - "require_exp": True, - "require_jti": True, + "require": ["iss", "exp", "jti", "aud"], "leeway": auth.OIDC_JWT_LEEWAY_SECONDS, }, } @@ -307,11 +304,11 @@ async def mock_is_token_revoked(jti): [ ( {"kid": "key-1", "alg": "RS256", "typ": "nested+jwt"}, - "unsupported token type", + "invalid token", ), ( {"kid": "key-1", "alg": "RS256", "cty": "JWT"}, - "unsupported token content type", + "invalid token", ), ], ) @@ -353,8 +350,8 @@ async def test_oidc_refreshes_jwks_when_kid_is_unknown( async def fake_jwks(force_refresh: bool = False) -> dict: refresh_calls.append(force_refresh) if force_refresh: - return {"keys": [{"kid": "new-key", "kty": "RSA"}]} - return {"keys": [{"kid": "old-key", "kty": "RSA"}]} + return {"keys": [{"kid": "new-key", "kty": "RSA", "n": "tVKUtcx_n9rt5afY_2WFNvU6PlFMggCrossDi6IGpYGH1xX0IEYf382aB0h4jVOhOaF_sHq1wVlZ1a1gDqF4Axtw89G2Fm_1WkK55Fq5i0Q5t-F4VwBv9lMxt70IIfn9Fj3f4E28z0qZ8a35P6UoMv_x2E2H-2A8-L1A_l4", "e": "AQAB"}]} + return {"keys": [{"kid": "old-key", "kty": "RSA", "n": "tVKUtcx_n9rt5afY_2WFNvU6PlFMggCrossDi6IGpYGH1xX0IEYf382aB0h4jVOhOaF_sHq1wVlZ1a1gDqF4Axtw89G2Fm_1WkK55Fq5i0Q5t-F4VwBv9lMxt70IIfn9Fj3f4E28z0qZ8a35P6UoMv_x2E2H-2A8-L1A_l4", "e": "AQAB"}]} observed: dict[str, object] = {} @@ -383,7 +380,7 @@ async def mock_is_token_revoked(jti): assert subject == "user-1" assert display_name == "User One" assert refresh_calls == [False, True] - assert observed["key"] == {"kid": "new-key", "kty": "RSA"} + assert observed["key"] is not None @pytest.mark.asyncio @@ -397,7 +394,7 @@ async def test_oidc_requires_jti_claim( ) async def fake_jwks() -> dict: - return {"keys": [{"kid": "key-1", "kty": "RSA"}]} + return {"keys": [{"kid": "key-1", "kty": "RSA", "n": "tVKUtcx_n9rt5afY_2WFNvU6PlFMggCrossDi6IGpYGH1xX0IEYf382aB0h4jVOhOaF_sHq1wVlZ1a1gDqF4Axtw89G2Fm_1WkK55Fq5i0Q5t-F4VwBv9lMxt70IIfn9Fj3f4E28z0qZ8a35P6UoMv_x2E2H-2A8-L1A_l4", "e": "AQAB"}]} monkeypatch.setattr(auth, "_get_jwks", fake_jwks) @@ -417,7 +414,7 @@ async def mock_is_token_revoked2(jti): ) assert exc_info.value.status_code == 401 - assert exc_info.value.detail == "token missing jti" + assert exc_info.value.detail == "invalid token" @pytest.mark.asyncio @@ -431,7 +428,7 @@ async def test_oidc_rejects_revoked_jti( ) async def fake_jwks() -> dict: - return {"keys": [{"kid": "key-1", "kty": "RSA"}]} + return {"keys": [{"kid": "key-1", "kty": "RSA", "n": "tVKUtcx_n9rt5afY_2WFNvU6PlFMggCrossDi6IGpYGH1xX0IEYf382aB0h4jVOhOaF_sHq1wVlZ1a1gDqF4Axtw89G2Fm_1WkK55Fq5i0Q5t-F4VwBv9lMxt70IIfn9Fj3f4E28z0qZ8a35P6UoMv_x2E2H-2A8-L1A_l4", "e": "AQAB"}]} expires_at = auth.dt.datetime.now(auth.dt.timezone.utc) + auth.dt.timedelta( minutes=5 @@ -464,7 +461,7 @@ async def mock_revoke(jti, ext): ) assert exc_info.value.status_code == 401 - assert exc_info.value.detail == "token revoked" + assert exc_info.value.detail == "invalid token" @pytest.mark.asyncio @@ -560,7 +557,7 @@ def mock_get_unverified_header(token): await auth._decode_verified_oidc_token("invalid_token") assert excinfo.value.status_code == 401 - assert excinfo.value.detail == "invalid token header" + assert excinfo.value.detail == "invalid token" @pytest.mark.asyncio @@ -575,7 +572,7 @@ async def test_oidc_decode_rejects_jwt_decode_error( ) async def fake_jwks() -> dict: - return {"keys": [{"kid": "key-1", "kty": "RSA"}]} + return {"keys": [{"kid": "key-1", "kty": "RSA", "n": "tVKUtcx_n9rt5afY_2WFNvU6PlFMggCrossDi6IGpYGH1xX0IEYf382aB0h4jVOhOaF_sHq1wVlZ1a1gDqF4Axtw89G2Fm_1WkK55Fq5i0Q5t-F4VwBv9lMxt70IIfn9Fj3f4E28z0qZ8a35P6UoMv_x2E2H-2A8-L1A_l4", "e": "AQAB"}]} def fail_decode(*_args: object, **_kwargs: object) -> dict: raise auth.jwt.PyJWTError("mocked decoding error") @@ -592,7 +589,7 @@ async def mock_is_token_revoked2(jti): await auth._decode_verified_oidc_token("Bearer token") assert exc_info.value.status_code == 401 - assert exc_info.value.detail == "token verification failed" + assert exc_info.value.detail == "invalid token" @pytest.mark.asyncio async def test_oidc_rejects_algorithm_key_type_mismatch( @@ -607,7 +604,7 @@ async def test_oidc_rejects_algorithm_key_type_mismatch( async def fake_jwks() -> dict: # JWK says RSA, but header says HS256 - return {"keys": [{"kid": "key-1", "kty": "RSA"}]} + return {"keys": [{"kid": "key-1", "kty": "RSA", "n": "tVKUtcx_n9rt5afY_2WFNvU6PlFMggCrossDi6IGpYGH1xX0IEYf382aB0h4jVOhOaF_sHq1wVlZ1a1gDqF4Axtw89G2Fm_1WkK55Fq5i0Q5t-F4VwBv9lMxt70IIfn9Fj3f4E28z0qZ8a35P6UoMv_x2E2H-2A8-L1A_l4", "e": "AQAB"}]} monkeypatch.setattr(auth, "_get_jwks", fake_jwks) @@ -625,7 +622,7 @@ def fail_decode(*_: object, **__: object) -> dict: await auth._decode_verified_oidc_token("ey...") assert exc_info.value.status_code == 401 - assert exc_info.value.detail == "algorithm/key type mismatch" + assert exc_info.value.detail == "invalid token" @pytest.mark.asyncio async def test_oidc_jwks_refresh_rate_limiting( monkeypatch: pytest.MonkeyPatch, @@ -647,7 +644,7 @@ async def get(self, url: str) -> _FakeHttpResponse: request_count += 1 if url.endswith("openid-configuration"): return _FakeHttpResponse({"jwks_uri": "https://issuer.example/jwks"}) - return _FakeHttpResponse({"keys": [{"kid": "new-key", "kty": "RSA"}]}) + return _FakeHttpResponse({"keys": [{"kid": "new-key", "kty": "RSA", "n": "tVKUtcx_n9rt5afY_2WFNvU6PlFMggCrossDi6IGpYGH1xX0IEYf382aB0h4jVOhOaF_sHq1wVlZ1a1gDqF4Axtw89G2Fm_1WkK55Fq5i0Q5t-F4VwBv9lMxt70IIfn9Fj3f4E28z0qZ8a35P6UoMv_x2E2H-2A8-L1A_l4", "e": "AQAB"}]}) monkeypatch.setattr(settings, "oidc_issuer", "https://issuer.example") monkeypatch.setattr(auth.httpx, "AsyncClient", FakeAsyncClient) @@ -665,12 +662,12 @@ async def get(self, url: str) -> _FakeHttpResponse: ) jwks = await auth._get_jwks() - assert jwks == {"keys": [{"kid": "new-key", "kty": "RSA"}]} + assert jwks == {"keys": [{"kid": "new-key", "kty": "RSA", "n": "tVKUtcx_n9rt5afY_2WFNvU6PlFMggCrossDi6IGpYGH1xX0IEYf382aB0h4jVOhOaF_sHq1wVlZ1a1gDqF4Axtw89G2Fm_1WkK55Fq5i0Q5t-F4VwBv9lMxt70IIfn9Fj3f4E28z0qZ8a35P6UoMv_x2E2H-2A8-L1A_l4", "e": "AQAB"}]} assert request_count == 2 before_second_refresh = request_count jwks2 = await auth._get_jwks(force_refresh=True) - assert jwks2 == {"keys": [{"kid": "new-key", "kty": "RSA"}]} + assert jwks2 == {"keys": [{"kid": "new-key", "kty": "RSA", "n": "tVKUtcx_n9rt5afY_2WFNvU6PlFMggCrossDi6IGpYGH1xX0IEYf382aB0h4jVOhOaF_sHq1wVlZ1a1gDqF4Axtw89G2Fm_1WkK55Fq5i0Q5t-F4VwBv9lMxt70IIfn9Fj3f4E28z0qZ8a35P6UoMv_x2E2H-2A8-L1A_l4", "e": "AQAB"}]} assert request_count == before_second_refresh @@ -696,7 +693,7 @@ async def get(self, url: str) -> _FakeHttpResponse: if url.endswith("openid-configuration"): return _FakeHttpResponse({"jwks_uri": "https://issuer.example/jwks"}) await asyncio.sleep(0) - return _FakeHttpResponse({"keys": [{"kid": "new-key", "kty": "RSA"}]}) + return _FakeHttpResponse({"keys": [{"kid": "new-key", "kty": "RSA", "n": "tVKUtcx_n9rt5afY_2WFNvU6PlFMggCrossDi6IGpYGH1xX0IEYf382aB0h4jVOhOaF_sHq1wVlZ1a1gDqF4Axtw89G2Fm_1WkK55Fq5i0Q5t-F4VwBv9lMxt70IIfn9Fj3f4E28z0qZ8a35P6UoMv_x2E2H-2A8-L1A_l4", "e": "AQAB"}]}) monkeypatch.setattr(settings, "oidc_issuer", "https://issuer.example") monkeypatch.setattr(auth.httpx, "AsyncClient", FakeAsyncClient) @@ -726,10 +723,37 @@ async def get(self, url: str) -> _FakeHttpResponse: ) assert refreshed == [ - {"keys": [{"kid": "new-key", "kty": "RSA"}]}, - {"keys": [{"kid": "new-key", "kty": "RSA"}]}, - {"keys": [{"kid": "new-key", "kty": "RSA"}]}, - {"keys": [{"kid": "new-key", "kty": "RSA"}]}, - {"keys": [{"kid": "new-key", "kty": "RSA"}]}, + {"keys": [{"kid": "new-key", "kty": "RSA", "n": "tVKUtcx_n9rt5afY_2WFNvU6PlFMggCrossDi6IGpYGH1xX0IEYf382aB0h4jVOhOaF_sHq1wVlZ1a1gDqF4Axtw89G2Fm_1WkK55Fq5i0Q5t-F4VwBv9lMxt70IIfn9Fj3f4E28z0qZ8a35P6UoMv_x2E2H-2A8-L1A_l4", "e": "AQAB"}]}, + {"keys": [{"kid": "new-key", "kty": "RSA", "n": "tVKUtcx_n9rt5afY_2WFNvU6PlFMggCrossDi6IGpYGH1xX0IEYf382aB0h4jVOhOaF_sHq1wVlZ1a1gDqF4Axtw89G2Fm_1WkK55Fq5i0Q5t-F4VwBv9lMxt70IIfn9Fj3f4E28z0qZ8a35P6UoMv_x2E2H-2A8-L1A_l4", "e": "AQAB"}]}, + {"keys": [{"kid": "new-key", "kty": "RSA", "n": "tVKUtcx_n9rt5afY_2WFNvU6PlFMggCrossDi6IGpYGH1xX0IEYf382aB0h4jVOhOaF_sHq1wVlZ1a1gDqF4Axtw89G2Fm_1WkK55Fq5i0Q5t-F4VwBv9lMxt70IIfn9Fj3f4E28z0qZ8a35P6UoMv_x2E2H-2A8-L1A_l4", "e": "AQAB"}]}, + {"keys": [{"kid": "new-key", "kty": "RSA", "n": "tVKUtcx_n9rt5afY_2WFNvU6PlFMggCrossDi6IGpYGH1xX0IEYf382aB0h4jVOhOaF_sHq1wVlZ1a1gDqF4Axtw89G2Fm_1WkK55Fq5i0Q5t-F4VwBv9lMxt70IIfn9Fj3f4E28z0qZ8a35P6UoMv_x2E2H-2A8-L1A_l4", "e": "AQAB"}]}, + {"keys": [{"kid": "new-key", "kty": "RSA", "n": "tVKUtcx_n9rt5afY_2WFNvU6PlFMggCrossDi6IGpYGH1xX0IEYf382aB0h4jVOhOaF_sHq1wVlZ1a1gDqF4Axtw89G2Fm_1WkK55Fq5i0Q5t-F4VwBv9lMxt70IIfn9Fj3f4E28z0qZ8a35P6UoMv_x2E2H-2A8-L1A_l4", "e": "AQAB"}]}, ] assert request_count == before_concurrent_refresh + 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "crit", + [ + "not-a-list", + [], + ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"], + [123], + ["unsupported-extension"] + ], +) +async def test_oidc_rejects_invalid_crit( + monkeypatch: pytest.MonkeyPatch, crit: object +) -> None: + monkeypatch.setattr(settings, "oidc_issuer", "https://issuer.example") + monkeypatch.setattr(settings, "oidc_audience", "pg-erd") + monkeypatch.setattr(auth.jwt, "get_unverified_header", lambda _: {"kid": "key-1", "alg": "RS256", "crit": crit}) + + with pytest.raises(HTTPException) as exc_info: + await auth._get_subject_from_request( + make_request({"Authorization": "Bearer token"}) + ) + + assert exc_info.value.status_code == 401 + assert exc_info.value.detail == "invalid token"