Skip to content

Commit 07ab252

Browse files
committed
fix(auth): normalize invalid bearer token handling
1 parent bd98453 commit 07ab252

2 files changed

Lines changed: 159 additions & 147 deletions

File tree

server/auth.py

Lines changed: 71 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,71 @@
1-
"""Lightweight auth helpers for future RBAC/JWT integration."""
2-
3-
import os
4-
from datetime import datetime, timedelta, timezone
5-
from typing import Any
6-
7-
import jwt
8-
9-
ROLE_ADMIN = "admin"
10-
ROLE_USER = "user"
11-
ALLOWED_ROLES = {ROLE_ADMIN, ROLE_USER}
12-
13-
14-
def _jwt_secret() -> str:
15-
return os.getenv("JWT_SECRET", "dev-jwt-secret")
16-
17-
18-
def _jwt_algorithm() -> str:
19-
return os.getenv("JWT_ALGORITHM", "HS256")
20-
21-
22-
def create_access_token(
23-
user_id: str,
24-
role: str,
25-
expires_hours: int = 24,
26-
) -> str:
27-
"""Create a JWT access token with basic role claims."""
28-
if role not in ALLOWED_ROLES:
29-
raise ValueError(f"Unsupported role: {role}")
30-
31-
now = datetime.now(timezone.utc)
32-
payload = {
33-
"sub": user_id,
34-
"role": role,
35-
"iat": now,
36-
"exp": now + timedelta(hours=max(expires_hours, 1)),
37-
}
38-
return jwt.encode(payload, _jwt_secret(), algorithm=_jwt_algorithm())
39-
40-
41-
def decode_access_token(token: str) -> dict[str, Any]:
42-
"""Decode and validate a JWT access token."""
43-
return jwt.decode(token, _jwt_secret(), algorithms=[_jwt_algorithm()])
44-
45-
46-
def extract_bearer_token(auth_header: str | None) -> str | None:
47-
"""Extract bearer token from an Authorization header string."""
48-
if not auth_header or not auth_header.startswith("Bearer "):
49-
return None
50-
token = auth_header.removeprefix("Bearer ").strip()
51-
return token or None
52-
53-
54-
def authenticate_request(
55-
auth_header: str | None,
56-
required_role: str | None = None,
57-
) -> dict[str, Any]:
58-
"""Validate a bearer token header and optionally enforce a role."""
59-
token = extract_bearer_token(auth_header)
60-
if token is None:
61-
raise PermissionError("Missing or invalid bearer token")
62-
63-
payload = decode_access_token(token)
64-
if required_role and payload.get("role") != required_role:
65-
raise PermissionError(f"Role '{required_role}' is required")
66-
67-
return payload
1+
"""Lightweight auth helpers for future RBAC/JWT integration."""
2+
3+
import os
4+
from datetime import datetime, timedelta, timezone
5+
from typing import Any
6+
7+
import jwt
8+
from jwt import InvalidTokenError
9+
10+
ROLE_ADMIN = "admin"
11+
ROLE_USER = "user"
12+
ALLOWED_ROLES = {ROLE_ADMIN, ROLE_USER}
13+
14+
15+
def _jwt_secret() -> str:
16+
return os.getenv("JWT_SECRET", "dev-jwt-secret")
17+
18+
19+
def _jwt_algorithm() -> str:
20+
return os.getenv("JWT_ALGORITHM", "HS256")
21+
22+
23+
def create_access_token(
24+
user_id: str,
25+
role: str,
26+
expires_hours: int = 24,
27+
) -> str:
28+
"""Create a JWT access token with basic role claims."""
29+
if role not in ALLOWED_ROLES:
30+
raise ValueError(f"Unsupported role: {role}")
31+
32+
now = datetime.now(timezone.utc)
33+
payload = {
34+
"sub": user_id,
35+
"role": role,
36+
"iat": now,
37+
"exp": now + timedelta(hours=max(expires_hours, 1)),
38+
}
39+
return jwt.encode(payload, _jwt_secret(), algorithm=_jwt_algorithm())
40+
41+
42+
def decode_access_token(token: str) -> dict[str, Any]:
43+
"""Decode and validate a JWT access token."""
44+
return jwt.decode(token, _jwt_secret(), algorithms=[_jwt_algorithm()])
45+
46+
47+
def extract_bearer_token(auth_header: str | None) -> str | None:
48+
"""Extract bearer token from an Authorization header string."""
49+
if not auth_header or not auth_header.startswith("Bearer "):
50+
return None
51+
token = auth_header.removeprefix("Bearer ").strip()
52+
return token or None
53+
54+
55+
def authenticate_request(
56+
auth_header: str | None,
57+
required_role: str | None = None,
58+
) -> dict[str, Any]:
59+
"""Validate a bearer token header and optionally enforce a role."""
60+
token = extract_bearer_token(auth_header)
61+
if token is None:
62+
raise PermissionError("Missing or invalid bearer token")
63+
64+
try:
65+
payload = decode_access_token(token)
66+
except InvalidTokenError as exc:
67+
raise PermissionError("Invalid or expired bearer token") from exc
68+
if required_role and payload.get("role") != required_role:
69+
raise PermissionError(f"Role '{required_role}' is required")
70+
71+
return payload

server/tests/test_auth.py

Lines changed: 88 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,80 +1,88 @@
1-
from jwt import InvalidTokenError
2-
3-
from server.auth import (
4-
ROLE_ADMIN,
5-
ROLE_USER,
6-
authenticate_request,
7-
create_access_token,
8-
decode_access_token,
9-
extract_bearer_token,
10-
)
11-
12-
13-
def test_create_and_decode_access_token_round_trip():
14-
token = create_access_token("alice", ROLE_ADMIN, expires_hours=1)
15-
16-
payload = decode_access_token(token)
17-
18-
assert payload["sub"] == "alice"
19-
assert payload["role"] == ROLE_ADMIN
20-
21-
22-
def test_create_access_token_rejects_unknown_role():
23-
try:
24-
create_access_token("alice", "superadmin")
25-
assert False, "Expected ValueError for unsupported role"
26-
except ValueError as exc:
27-
assert "Unsupported role" in str(exc)
28-
29-
30-
def test_decode_access_token_rejects_invalid_token():
31-
try:
32-
decode_access_token("not-a-token")
33-
assert False, "Expected InvalidTokenError"
34-
except InvalidTokenError:
35-
assert True
36-
37-
38-
def test_extract_bearer_token_valid_header():
39-
token = extract_bearer_token("Bearer abc.def.ghi")
40-
assert token == "abc.def.ghi"
41-
42-
43-
def test_extract_bearer_token_invalid_header():
44-
assert extract_bearer_token(None) is None
45-
assert extract_bearer_token("") is None
46-
assert extract_bearer_token("Basic xyz") is None
47-
assert extract_bearer_token("Bearer ") is None
48-
49-
50-
def test_user_role_token_supported():
51-
token = create_access_token("bob", ROLE_USER, expires_hours=1)
52-
payload = decode_access_token(token)
53-
assert payload["role"] == ROLE_USER
54-
55-
56-
def test_authenticate_request_accepts_valid_bearer_token():
57-
token = create_access_token("alice", ROLE_ADMIN, expires_hours=1)
58-
59-
payload = authenticate_request(f"Bearer {token}")
60-
61-
assert payload["sub"] == "alice"
62-
assert payload["role"] == ROLE_ADMIN
63-
64-
65-
def test_authenticate_request_rejects_missing_bearer_token():
66-
try:
67-
authenticate_request(None)
68-
assert False, "Expected PermissionError"
69-
except PermissionError as exc:
70-
assert "Missing or invalid bearer token" in str(exc)
71-
72-
73-
def test_authenticate_request_enforces_required_role():
74-
token = create_access_token("bob", ROLE_USER, expires_hours=1)
75-
76-
try:
77-
authenticate_request(f"Bearer {token}", required_role=ROLE_ADMIN)
78-
assert False, "Expected PermissionError"
79-
except PermissionError as exc:
80-
assert "Role 'admin' is required" in str(exc)
1+
from jwt import InvalidTokenError
2+
3+
from server.auth import (
4+
ROLE_ADMIN,
5+
ROLE_USER,
6+
authenticate_request,
7+
create_access_token,
8+
decode_access_token,
9+
extract_bearer_token,
10+
)
11+
12+
13+
def test_create_and_decode_access_token_round_trip():
14+
token = create_access_token("alice", ROLE_ADMIN, expires_hours=1)
15+
16+
payload = decode_access_token(token)
17+
18+
assert payload["sub"] == "alice"
19+
assert payload["role"] == ROLE_ADMIN
20+
21+
22+
def test_create_access_token_rejects_unknown_role():
23+
try:
24+
create_access_token("alice", "superadmin")
25+
assert False, "Expected ValueError for unsupported role"
26+
except ValueError as exc:
27+
assert "Unsupported role" in str(exc)
28+
29+
30+
def test_decode_access_token_rejects_invalid_token():
31+
try:
32+
decode_access_token("not-a-token")
33+
assert False, "Expected InvalidTokenError"
34+
except InvalidTokenError:
35+
assert True
36+
37+
38+
def test_extract_bearer_token_valid_header():
39+
token = extract_bearer_token("Bearer abc.def.ghi")
40+
assert token == "abc.def.ghi"
41+
42+
43+
def test_extract_bearer_token_invalid_header():
44+
assert extract_bearer_token(None) is None
45+
assert extract_bearer_token("") is None
46+
assert extract_bearer_token("Basic xyz") is None
47+
assert extract_bearer_token("Bearer ") is None
48+
49+
50+
def test_user_role_token_supported():
51+
token = create_access_token("bob", ROLE_USER, expires_hours=1)
52+
payload = decode_access_token(token)
53+
assert payload["role"] == ROLE_USER
54+
55+
56+
def test_authenticate_request_accepts_valid_bearer_token():
57+
token = create_access_token("alice", ROLE_ADMIN, expires_hours=1)
58+
59+
payload = authenticate_request(f"Bearer {token}")
60+
61+
assert payload["sub"] == "alice"
62+
assert payload["role"] == ROLE_ADMIN
63+
64+
65+
def test_authenticate_request_rejects_missing_bearer_token():
66+
try:
67+
authenticate_request(None)
68+
assert False, "Expected PermissionError"
69+
except PermissionError as exc:
70+
assert "Missing or invalid bearer token" in str(exc)
71+
72+
73+
def test_authenticate_request_enforces_required_role():
74+
token = create_access_token("bob", ROLE_USER, expires_hours=1)
75+
76+
try:
77+
authenticate_request(f"Bearer {token}", required_role=ROLE_ADMIN)
78+
assert False, "Expected PermissionError"
79+
except PermissionError as exc:
80+
assert "Role 'admin' is required" in str(exc)
81+
82+
83+
def test_authenticate_request_rejects_invalid_bearer_token():
84+
try:
85+
authenticate_request("Bearer not-a-token")
86+
assert False, "Expected PermissionError"
87+
except PermissionError as exc:
88+
assert "Invalid or expired bearer token" in str(exc)

0 commit comments

Comments
 (0)