🔒️(backend) secure native app OIDC login with exchange codes

Add one-time exchange code mechanism for native app OIDC login.
Instead of exposing session ID in redirect URL, generates a
short-lived single-use code stored in Redis. Native apps exchange
this code for the session ID via a dedicated API endpoint.

Includes NativeAppRedirect for custom URL schemes, rate limiting,
logging, and whitelist of allowed schemes.

Closes #1153

Co-Authored-By: gigi206
This commit is contained in:
Michel-Marie MAUDET
2026-03-20 18:35:49 +01:00
parent 4f2c4bfaf9
commit 724a4ef3df
72 changed files with 846 additions and 1755 deletions
+1 -10
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "1.11.0"
version = "1.10.0"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",
@@ -21,16 +21,8 @@ dependencies = [
[project.optional-dependencies]
dev = [
"ruff==0.14.4",
"pytest==9.0.2",
"responses>=0.25.8",
]
[tool.pytest.ini_options]
markers = [
"api: Test the API",
]
testpaths = ["tests"]
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
@@ -57,7 +49,6 @@ select = [
"T20", # flake8-print
"W", # pycodestyle warning
]
ignore= ["PLR2004"]
[tool.ruff.lint.per-file-ignores]
"tests/*" = [
+2 -1
View File
@@ -23,7 +23,8 @@ Einige Punkte, die wir Ihnen empfehlen zu überprüfen:
""",
download_header_template=(
"\n*[Laden Sie hier Ihre Aufnahme herunter (externer Link)]({download_link})*\n"
"\n*Laden Sie Ihre Aufnahme herunter, "
"indem Sie [diesem Link folgen]({download_link})*\n"
),
hallucination_replacement_text="[Text konnte nicht transkribiert werden]",
document_default_title="Transkription",
+1 -1
View File
@@ -23,7 +23,7 @@ A few things we recommend you check:
""",
download_header_template=(
"\n*[Download your recording (external link)]({download_link})*\n"
"\n*Download your recording by [following this link]({download_link})*\n"
),
hallucination_replacement_text="[Unable to transcribe text]",
document_default_title="Transcription",
+1 -1
View File
@@ -23,7 +23,7 @@ Quelques points que nous vous conseillons de vérifier :
""",
download_header_template=(
"\n*[Télécharger votre enregistrement (lien externe)]({download_link})*\n"
"\n*Télécharger votre enregistrement en [suivant ce lien]({download_link})*\n"
),
hallucination_replacement_text="[Texte impossible à transcrire]",
document_default_title="Transcription",
+1 -1
View File
@@ -23,7 +23,7 @@ Een paar punten die wij u aanraden te controleren:
""",
download_header_template=(
"\n*[Download hier je opname (externe link)]({download_link})*\n"
"\n*Download uw opname door [deze link te volgen]({download_link})*\n"
),
hallucination_replacement_text="[Tekst kon niet worden getranscribeerd]",
document_default_title="Transcriptie",
-1
View File
@@ -1 +0,0 @@
"""Tests for the summary service."""
-1
View File
@@ -1 +0,0 @@
"""Tests for the API summary service."""
-21
View File
@@ -1,21 +0,0 @@
"""Integration tests for the health check endpoints."""
class TestHeartbeat:
"""Tests for the /__heartbeat__ endpoint."""
def test_returns_200(self, client):
"""The heartbeat endpoint responds with 200 OK without a token."""
response = client.get("/__heartbeat__")
assert response.status_code == 200
class TestLBHeartbeat:
"""Tests for the /__lbheartbeat__ endpoint."""
def test_returns_200(self, client):
"""The load-balancer heartbeat endpoint responds with 200 OK without a token."""
response = client.get("/__lbheartbeat__")
assert response.status_code == 200
-88
View File
@@ -1,88 +0,0 @@
"""Integration tests for the API tasks endpoints."""
# tests/unit/test_api_tasks.py
from unittest.mock import MagicMock, patch
class TestTasks:
"""Tests for the /tasks endpoint."""
@patch(
"summary.api.route.tasks.process_audio_transcribe_summarize_v2.apply_async",
return_value=MagicMock(id="task-id-abc"),
)
@patch("summary.api.route.tasks.time.time", return_value=1735725600.0)
def test_create_task_returns_task_id(self, mock_time, mock_apply_async, client):
"""POST /tasks/ with valid payload returns id and dispatches Celery task."""
response = client.post(
"api/v1/tasks/",
headers={"Authorization": "Bearer test-api-token"},
json={
"owner_id": "owner-123",
"filename": "recording.mp4",
"email": "user@example.com",
"sub": "sub-123",
"room": "room-abc",
"recording_date": "2026-01-01",
"recording_time": "10:00:00",
"language": None,
"download_link": "http://example.com/file.mp4",
},
)
assert response.status_code == 200
assert response.json() == {"id": "task-id-abc", "message": "Task created"}
args = mock_apply_async.call_args.kwargs["args"]
assert args == [
"owner-123", # owner_id
"recording.mp4", # filename
"user@example.com", # email
"sub-123", # sub
1735725600.0, # frozen time
"room-abc", # room
"2026-01-01", # recording_date
"10:00:00", # recording_time
None, # language
"http://example.com/file.mp4", # download_link
None, # context_language
]
def test_create_task_invalid_language(self, client):
"""POST /tasks/ with an unsupported language returns 422."""
payload = {"language": "klingon"}
response = client.post(
"/api/v1/tasks/",
headers={"Authorization": "Bearer test-api-token"},
json=payload,
)
assert response.status_code == 422
@patch(
"summary.api.route.tasks.AsyncResult",
return_value=MagicMock(status="PENDING"),
)
def test_get_task_status_pending(self, mock_result, client):
"""GET /tasks/{id} returns PENDING status when the task has not started yet."""
response = client.get(
"/api/v1/tasks/task-id-abc",
headers={"Authorization": "Bearer test-api-token"},
)
assert response.status_code == 200
assert response.json() == {"id": "task-id-abc", "status": "PENDING"}
@patch(
"summary.api.route.tasks.AsyncResult",
return_value=MagicMock(status="SUCCESS"),
)
def test_get_task_status_success(self, mock_result, client):
"""GET /tasks/{id} returns SUCCESS status when the task has completed."""
response = client.get(
"/api/v1/tasks/task-id-abc",
headers={"Authorization": "Bearer test-api-token"},
)
assert response.status_code == 200
assert response.json()["status"] == "SUCCESS"
-24
View File
@@ -1,24 +0,0 @@
"""Integration test configuration. Provides shared fixtures."""
import pytest
from fastapi.testclient import TestClient
from pydantic import SecretStr
from summary.core.config import Settings, get_settings
from summary.main import app
def get_settings_override():
"""Return settings for tests."""
return Settings(
app_api_token=SecretStr("test-api-token"),
)
@pytest.fixture()
def client():
"""Provide a FastAPI TestClient for tests."""
client = TestClient(app)
app.dependency_overrides[get_settings] = get_settings_override
return client