Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions newsroom/news_api/api_tokens/auth.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import ipaddress
import base64
from typing import Any
from datetime import timedelta
from quart_babel import gettext
Expand Down Expand Up @@ -48,22 +49,41 @@ async def support_auth_token_in_url(request: Request) -> None:
return None


async def support_auth_basic_auth(request: Request) -> None:
"""Flag to indicate this endpoint supports using the basic auth"""
return None


class CompanyTokenAuth(UserAuthProtocol):
def get_token_from_request(self, request: Request) -> str | None:
"""
Extracts the token from `Authorization` header. Code taken partly
from eve.Auth module
"""
supports_basic_auth = (
isinstance(request.endpoint.auth, list) and support_auth_basic_auth in request.endpoint.auth
) or (isinstance(request.endpoint.auth, dict) and support_auth_basic_auth in request.endpoint.auth.values())
supports_token_in_url = (
isinstance(request.endpoint.auth, list) and support_auth_token_in_url in request.endpoint.auth
) or (isinstance(request.endpoint.auth, dict) and support_auth_token_in_url in request.endpoint.auth.values())

auth = (request.get_header("Authorization") or "").strip()
if len(auth):
if auth.lower().startswith(("token", "bearer")):
return auth.split(" ")[1] if " " in auth else None

if supports_basic_auth and auth.lower().startswith("basic"):
base64_payload = auth.split(" ", 1)[1]

credentials_bytes = base64.b64decode(base64_payload)
credentials = credentials_bytes.decode("utf-8")
username, password = credentials.split(":", 1)

return username if username else password
Comment on lines +75 to +82
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Basic Auth parsing logic lacks error handling for malformed input. The code should handle potential exceptions from:

  1. auth.split(" ", 1)[1] when there's no space after "Basic"
  2. base64.b64decode(base64_payload) when the payload is invalid base64
  3. credentials_bytes.decode("utf-8") when the decoded bytes are not valid UTF-8
  4. credentials.split(":", 1) when the credentials don't contain a colon

Without proper error handling, malformed Basic Auth headers will cause unhandled exceptions. Consider wrapping this logic in a try-except block and returning None on any parsing errors, allowing the authentication to fail gracefully.

Copilot uses AI. Check for mistakes.

# In case just the token was passed
return auth

supports_token_in_url = (
isinstance(request.endpoint.auth, list) and support_auth_token_in_url in request.endpoint.auth
) or (isinstance(request.endpoint.auth, dict) and support_auth_token_in_url in request.endpoint.auth.values())
if supports_token_in_url:
token = request.get_url_arg("token") or request.get_view_args("token")
if token:
Expand Down
6 changes: 3 additions & 3 deletions newsroom/news_api/news/assets/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from newsroom.assets import get_upload
from newsroom.news_api.utils import post_api_audit
from newsroom.news_api.api_tokens.auth import support_auth_token_in_url
from newsroom.news_api.api_tokens.auth import support_auth_token_in_url, support_auth_basic_auth

assets_endpoints = EndpointGroup("assets", __name__)

Expand All @@ -26,7 +26,7 @@ class RouteArguments(BaseModel):
"assets/<path:asset_id>/<item_id>",
title="Download Asset (with Wire ID)",
methods=["GET"],
auth=[support_auth_token_in_url],
auth=[support_auth_token_in_url, support_auth_basic_auth],
)
async def download(args: RouteArguments, params: RouteParams, request: Request):
"""
Expand All @@ -42,7 +42,7 @@ async def download(args: RouteArguments, params: RouteParams, request: Request):
"assets/<string:asset_id>",
title="Download Asset",
methods=["GET"],
auth=[support_auth_token_in_url],
auth=[support_auth_token_in_url, support_auth_basic_auth],
)
async def get_item(args: RouteArguments, params: RouteParams, request: Request):
"""
Expand Down
4 changes: 2 additions & 2 deletions newsroom/news_api/news/atom/views.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from superdesk.core.web import EndpointGroup
from superdesk.core.types import Request, BaseModel, Response

from newsroom.news_api.api_tokens.auth import support_auth_token_in_url
from newsroom.news_api.api_tokens.auth import support_auth_token_in_url, support_auth_basic_auth
from newsroom.news_api.formatters import AtomFormatter

atom_endpoints = EndpointGroup("atom", __name__)
Expand All @@ -25,7 +25,7 @@ async def get_atom_token(args: AtomArgs, params: None, request: Request) -> Resp
"atom",
title="ATOM Feed (Header auth)",
methods=["GET"],
auth=[support_auth_token_in_url],
auth=[support_auth_basic_auth, support_auth_token_in_url],
)
async def get_atom_authed(args: None, params: AtomArgs, request: Request) -> Response:
return await AtomFormatter().format_feed(params.token, request)
9 changes: 7 additions & 2 deletions newsroom/news_api/news/item/item.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from newsroom.settings import get_setting
from newsroom.news_api.utils import post_api_audit
from newsroom.history_async import HistoryService

from newsroom.news_api.api_tokens.auth import support_auth_token_in_url, support_auth_basic_auth

news_item_endpoints = EndpointGroup("news/item", __name__)

Expand All @@ -26,7 +26,12 @@ class RouteParams(BaseModel):
format: str = "NINJSFormatter"


@news_item_endpoints.endpoint("news/item/<path:item_id>", title="Get News Item", methods=["GET"])
@news_item_endpoints.endpoint(
"news/item/<path:item_id>",
title="Get News Item",
methods=["GET"],
auth=[support_auth_token_in_url, support_auth_basic_auth],
)
async def get_item(args: RouteArguments, params: RouteParams, request: Request) -> Response:
app = get_current_app()
formatter = get_formatter_by_classname(params.format)
Expand Down
4 changes: 2 additions & 2 deletions newsroom/news_api/news/rss/views.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from superdesk.core.web import EndpointGroup
from superdesk.core.types import Request, BaseModel, Response

from newsroom.news_api.api_tokens.auth import support_auth_token_in_url
from newsroom.news_api.api_tokens.auth import support_auth_token_in_url, support_auth_basic_auth
from newsroom.news_api.formatters import RSSFormatter

rss_endpoints = EndpointGroup("rss", __name__)
Expand All @@ -25,7 +25,7 @@ async def get_rss_token(args: RSSArgs, params: None, request: Request) -> Respon
"rss",
title="RSS Feed (Header auth)",
methods=["GET"],
auth=[support_auth_token_in_url],
auth=[support_auth_basic_auth, support_auth_token_in_url],
)
async def get_rss_authed(args: None, params: RSSArgs, request: Request) -> Response:
return await RSSFormatter().format_feed(params.token, request)
16 changes: 16 additions & 0 deletions tests/news_api/test_api_assets.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import base64
import os
from bson import ObjectId
from tests.news_api.test_api_audit import audit_check
Expand Down Expand Up @@ -30,6 +31,21 @@ async def test_get_asset(client, app):
response = await client.get("api/v1/assets/{}".format(image_id), headers={"Authorization": token.get("token")})
assert response.status_code == 200
await audit_check(str(image_id))
response = await client.get(
"api/v1/assets/{}".format(image_id), headers={"Authorization": f"Bearer {token.get('token')}"}
)
assert response.status_code == 200
response = await client.get(
"api/v1/assets/{}".format(image_id), headers={"Authorization": f"Token {token.get('token')}"}
)
assert response.status_code == 200
credentials_string = f"{token.get('token')}:password"
credentials_bytes = credentials_string.encode("utf-8")
encoded_payload = base64.b64encode(credentials_bytes).decode("utf-8")
response = await client.get(
"api/v1/assets/{}".format(image_id), headers={"Authorization": f"Basic {encoded_payload}"}
)
assert response.status_code == 200


async def test_authorization_get_asset(client, app):
Expand Down
39 changes: 39 additions & 0 deletions tests/news_api/test_news_api.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import base64

from bson import ObjectId
import lxml.etree
from newsroom.types import SectionEnum
Expand Down Expand Up @@ -181,3 +183,40 @@ async def test_product_search(client, app):
f"/api/v1/news/search/?products={wire_product_id}", headers={"Authorization": token.get("token")}
)
assert response.status_code == 400


async def test_get_atom_and_rss_auth(client, app):
company_id = ObjectId()
await create_entries_for(
"companies",
[
{
"_id": company_id,
"name": "Test Company",
"is_enabled": True,
"products": [],
"sections": {"news_api": True, "wire": True},
}
],
)
await create_entries_for("news_api_tokens", [{"company": company_id, "enabled": True}])
token = await find_one_for("news_api_tokens", company=company_id)

response = await client.get("/api/v1/rss", headers={"Authorization": token.get("token")})
assert response.status_code == 200
response = await client.get("/api/v1/atom", headers={"Authorization": "Bearer " + token.get("token")})
assert response.status_code == 200

credentials_string = f"{token.get('token')}:password"
credentials_bytes = credentials_string.encode("utf-8")
encoded_payload = base64.b64encode(credentials_bytes).decode("utf-8")

response = await client.get("api/v1/rss", headers={"Authorization": f"Basic {encoded_payload}"})
assert response.status_code == 200
response = await client.get("api/v1/atom", headers={"Authorization": f"Basic {encoded_payload}"})

assert response.status_code == 200
response = await client.get(f"api/v1/atom/{token.get('token')}")
assert response.status_code == 200
response = await client.get(f"api/v1/rss/{token.get('token')}")
assert response.status_code == 200
Loading