-
Notifications
You must be signed in to change notification settings - Fork 0
π‘οΈ Sentinel: [MEDIUM] JWT 보μ κ°μ #986
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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), | ||||||||||||||||||||||||||||||
|
Comment on lines
285
to
289
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π Security & Privacy | π Major | β‘ Quick win π Supported by static analysisπ Script executed: #!/usr/bin/env bash
set -euo pipefail
cd backend
uv run python - <<'PY'
import jwt
from cryptography.hazmat.primitives.asymmetric import rsa
from jwt import PyJWK
from jwt.exceptions import InvalidAlgorithmError
print("PyJWT:", jwt.__version__)
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
jwk = jwt.algorithms.RSAAlgorithm.to_jwk(
private_key.public_key(), as_dict=True
)
jwk.update({"kid": "key-1", "alg": "RS256"})
token = jwt.encode({"sub": "user-1"}, private_key, algorithm="PS256")
# νμ¬ κ΅¬νμ²λΌ raw keyλ₯Ό μ¬μ©νλ©΄ ν΅κ³Όνλμ§ νμΈνλ€.
jwt.decode(token, PyJWK.from_dict(jwk).key, algorithms=["PS256"])
# PyJWK κ°μ²΄λ₯Ό μ¬μ©νλ©΄ JWKμ RS256 λ°μΈλ©μ κ±°λΆν΄μΌ νλ€.
try:
jwt.decode(token, PyJWK.from_dict(jwk), algorithms=["PS256"])
except InvalidAlgorithmError:
pass
else:
raise AssertionError("PyJWK algorithm binding was not enforced")
PYRepository: ContextualWisdomLab/pg-erd-cloud Length of output: 210 π Script executed: #!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '--- backend/app/auth.py ---'
sed -n '1,25p;245,325p' backend/app/auth.py
printf '%s\n' '--- dependency declarations ---'
fd -a -t f 'pyproject.toml|uv.lock|requirements.*|poetry.lock' backend . | sort -u
for f in $(fd -a -t f 'pyproject.toml|uv.lock|requirements.*|poetry.lock' backend . | sort -u); do
printf '\n--- %s ---\n' "$f"
rg -n -C 3 'PyJWT|pyjwt|python-jose|cryptography' "$f" || true
doneRepository: ContextualWisdomLab/pg-erd-cloud Length of output: 12527 π Web query:
π‘ Result: In PyJWT 2.13.0, significant changes were made to how Citations:
π Web query:
π‘ Result: In PyJWT, PyJWK is a class defined in jwt/api_jwk.py used to represent a JSON Web Key (JWK) [1]. The from_dict static method is a factory that creates a PyJWK instance from a dictionary containing JWK data [1]. When a PyJWK instance is initialized, it determines the associated algorithm (bound to the self.algorithm_name attribute) based on either an explicitly provided algorithm argument or, if not specified, by inspecting the alg field within the provided JWK data [1]. The interaction between PyJWK and the signature verification process in jwt/api_jws.py was the subject of a security fix (GHSA-jq35-7prp-9v3f) included in PyJWT 2.13.0 [2][3]. Previously, when decoding a JWT using a PyJWK object, the signature verification logic in _verify_signature would use the algorithm bound to the PyJWK instance regardless of the algorithm specified in the JWT header, potentially allowing a bypass of the caller's algorithm allow-list [3]. To address this, the logic was updated to bind the JWT header alg to the PyJWK.algorithm_name during verification [2][4]. In the current implementation of _verify_signature in jwt/api_jws.py, if the key provided is an instance of PyJWK, the verification process now explicitly checks if the algorithm from the JWT header (alg) matches the algorithm bound to the PyJWK instance (key.algorithm_name) [5]. If they do not match, an InvalidAlgorithmError is raised, ensuring that the verification behavior remains consistent with the expected algorithm [5][3]. Citations:
π Web query:
π‘ Result: In PyJWT 2.13.0, the Citations:
π€ Prompt for AI AgentsSource: MCP tools |
||||||||||||||||||||||||||||||
| 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, | ||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||
|
Comment on lines
292
to
296
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π‘ JWT clock-skew tolerance silently disabled The 60-second clock-skew tolerance is passed inside the PyJWT
Suggested change
Was this helpful? React with π or π to provide feedback.
Comment on lines
292
to
296
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π― Functional Correctness | π‘ Minor | β‘ Quick win π Supported by static analysisπ Script executed: #!/usr/bin/env bash
set -euo pipefail
cd backend
uv run python - <<'PY'
import time
import jwt
print("PyJWT:", jwt.__version__)
token = jwt.encode(
{"exp": int(time.time()) - 1},
"secret",
algorithm="HS256",
)
try:
jwt.decode(
token,
"secret",
algorithms=["HS256"],
options={"leeway": 60},
)
except jwt.ExpiredSignatureError:
print("options['leeway'] is ignored")
jwt.decode(token, "secret", algorithms=["HS256"], leeway=60)
PYRepository: ContextualWisdomLab/pg-erd-cloud Length of output: 210 π Script executed: #!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '--- changed auth hunk ---'
git diff --unified=35 -- backend/app/auth.py
printf '%s\n' '--- relevant dependency declarations ---'
rg -n -C 3 'PyJWT|pyjwt|jwt' backend/pyproject.toml backend/uv.lock backend/requirements*.txt 2>/dev/null || true
printf '%s\n' '--- focused test assertions ---'
sed -n '270,315p' backend/tests/test_auth_security.py 2>/dev/null || trueRepository: ContextualWisdomLab/pg-erd-cloud Length of output: 1857 π Script executed: #!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '--- auth decode call and surrounding flow ---'
sed -n '250,310p' backend/app/auth.py
printf '%s\n' '--- dependency and lockfile inventory ---'
git ls-files backend | rg '(^|/)(pyproject\.toml|uv\.lock|requirements[^/]*\.txt)$' || true
rg -n -C 5 'pyjwt|PyJWT|leeway' backend --glob '*.py' --glob '*.toml' --glob '*.lock' --glob '*.txt'Repository: ContextualWisdomLab/pg-erd-cloud Length of output: 8485 π Web query:
π‘ Result: In PyJWT 2.13.0, the leeway parameter is used to provide a time margin to account for clock skew when validating time-based claims, specifically 'exp' (expiration) and 'nbf' (not before) [1][2]. In the source code (jwt/api_jwt.py), the leeway parameter is defined as a float or datetime.timedelta [3]. When passed to the jwt.decode function, it is used within the _validate_claims method to adjust the timing checks [3][4]. If a datetime.timedelta instance is provided, it is converted to total seconds using.total_seconds [3][4]. Key details regarding its usage include: 1. Functionality: - For 'exp': It allows the token to remain valid for a small period after the expiration time has passed, helping to avoid rejection due to minor clock differences [1][2]. - For 'nbf': It allows the token to be validated even if the 'nbf' time is slightly in the future [1][2]. 2. Implementation: - The leeway is applied in the _validate_exp and _validate_nbf internal methods by comparing the token claim against the current time (now) adjusted by the leeway [4]. - The default value for leeway is 0 [5][6][7]. You can pass leeway directly to the jwt.decode call as a keyword argument [1][6]. Example: decoded = jwt.decode(token, "secret", leeway=5, algorithms=["HS256"]) # Alternatively, using timedelta decoded = jwt.decode(token, "secret", leeway=datetime.timedelta(seconds=5), algorithms=["HS256"]) Citations:
PyJWT 2.13.0μ π€ Prompt for AI AgentsSource: MCP tools |
||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| 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, | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π Info: Stale python-jose type stubs remain
(Refers to this code) Was this helpful? React with π or π to provide feedback. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π Security & Privacy | π‘ Minor | β‘ Quick win JWKS κ°±μ ν νμ¬ old-keyμ new-keyλ old-keyμ new-keyμ λ€λ₯Έ 곡κ°ν€ μλ£λ₯Ό μ¬μ©νκ³ As per coding guidelines: π€ Prompt for AI AgentsSource: Coding guidelines |
||
|
|
||
|
|
||
| @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" | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
π Info: crit header rejects every token that carries it
The validation loop unconditionally raises on the first item, so any token with a
critheader is rejected regardless of value. This matches the intent of supporting no critical extensions, but the per-itemisinstancecheck never runs past the first element.Was this helpful? React with π or π to provide feedback.