Skip to content
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

6 add OIDC auth to mail api #12

Merged
merged 4 commits into from
Feb 13, 2024
Merged
Show file tree
Hide file tree
Changes from 2 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
350 changes: 334 additions & 16 deletions poetry.lock

Large diffs are not rendered by default.

23 changes: 22 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,39 @@ packages = [

[tool.poetry.dependencies]
python = "^3.12"
fastapi = {extras = ["all"], version = "^0.109.0"}
fastapi = {extras = ["all"], version = "^0.104.1"}
uvicorn = "^0.27.0.post1"
pyyaml = "^6.0.1"
email-validator = "^2.1.0.post1"
python-multipart = "^0.0.7"
regtech-api-commons = {git = "https://github.com/cfpb/regtech-api-commons.git"}


[tool.poetry.group.dev.dependencies]
pytest = "^8.0.0"
black = "^24.1.1"
ruff = "^0.1.15"
pytest-mock = "^3.12.0"
pytest-env = "^1.1.3"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

[tool.pytest.ini_options]
pythonpath = ["regtech_mail_api"]
testpaths = ["tests"]
env = [
"KC_URL=http://localhost",
"KC_REALM=",
"KC_ADMIN_CLIENT_ID=",
"KC_ADMIN_CLIENT_SECRET=",
"KC_REALM_URL=http://localhost",
"AUTH_URL=http://localhost",
"TOKEN_URL=http://localhost",
"CERTS_URL=http://localhost",
"AUTH_CLIENT=",
"EMAIL_MAILER=mock",
"[email protected]",
"[email protected]"
]
40 changes: 34 additions & 6 deletions regtech_mail_api/api.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,38 @@
from fastapi import FastAPI, Request
from regtech_mail_api.mailer import Mailer, MockMailer, SmtpMailer
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import OAuth2AuthorizationCodeBearer

from regtech_mail_api.mailer import Mailer, MockMailer, SmtpMailer
from regtech_mail_api.models import Email
from regtech_mail_api.settings import EmailApiSettings, EmailMailerType
from regtech_mail_api.settings import EmailApiSettings, EmailMailerType, kc_settings

from starlette.authentication import requires
from starlette.middleware.authentication import AuthenticationMiddleware

from regtech_api_commons.oauth2.oauth2_backend import BearerTokenAuthBackend
from regtech_api_commons.oauth2.oauth2_admin import OAuth2Admin

app = FastAPI()

token_bearer = OAuth2AuthorizationCodeBearer(
authorizationUrl=kc_settings.auth_url.unicode_string(),
tokenUrl=kc_settings.token_url.unicode_string(),
)

app.add_middleware(
AuthenticationMiddleware,
backend=BearerTokenAuthBackend(token_bearer, OAuth2Admin(kc_settings)),
)
app.add_middleware(
CORSMiddleware,
allow_origins=[
"*"
], # thinking this should be derived from an env var from docker-compose or helm values
allow_methods=["GET", "POST"],
allow_headers=["authorization"],
)


# FIXME: Come up with a better way of handling these settings
# without having to do all the `type: ignore`s
settings = EmailApiSettings() # type: ignore
Expand All @@ -27,9 +54,11 @@


@app.get("/")
def read_root():
@requires("authenticated")
def read_root(request: Request):
return {"message": "Welcome to the Email API"}


# TODO: Remove this once out of initial dev
@app.get("/debug")
async def get_debug_info(request: Request):
Expand All @@ -44,6 +73,7 @@ async def get_debug_info(request: Request):


@app.post("/send")
@requires("authenticated")
async def send_email(request: Request):
headers = request.headers
subject = headers["X-Mail-Subject"]
Expand All @@ -61,6 +91,4 @@ async def send_email(request: Request):

mailer.send(email)

return {
"email": email
}
return {"email": email}
5 changes: 5 additions & 0 deletions regtech_mail_api/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from pydantic import EmailStr, SecretStr, model_validator
from pydantic_settings import BaseSettings

from regtech_api_commons.oauth2.config import KeycloakSettings


class EmailMailerType(str, Enum):
MOCK = "mock"
Expand Down Expand Up @@ -33,3 +35,6 @@ def check_smtp(self):
raise ValueError("SMTP host must be set when using SMTP email sender")

return self


kc_settings = KeycloakSettings()
99 changes: 99 additions & 0 deletions tests/test_authentication.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import pytest

from fastapi import FastAPI
from fastapi.testclient import TestClient
from pytest_mock import MockerFixture
from unittest.mock import Mock

from regtech_api_commons.models.auth import AuthenticatedUser
from starlette.authentication import AuthCredentials, UnauthenticatedUser


@pytest.fixture
def app_fixture(mocker: MockerFixture) -> FastAPI:
from api import app

return app


@pytest.fixture
def auth_mock(mocker: MockerFixture) -> Mock:
return mocker.patch(
"regtech_api_commons.oauth2.oauth2_backend.BearerTokenAuthBackend.authenticate"
)


@pytest.fixture
def authed_user_mock(auth_mock: Mock) -> Mock:
claims = {
"preferred_username": "test_user",
"email": "[email protected]",
}
auth_mock.return_value = (
AuthCredentials(["authenticated"]),
AuthenticatedUser.from_claim(claims),
)
return auth_mock


@pytest.fixture
def unauthed_user_mock(auth_mock: Mock) -> Mock:
auth_mock.return_value = (AuthCredentials("unauthenticated"), UnauthenticatedUser())
return auth_mock


class TestEmailApiAuthentication:

def test_unauthed_endpoints(
self, mocker: MockerFixture, app_fixture: FastAPI, unauthed_user_mock: Mock
):
client = TestClient(app_fixture)
res = client.get("/")
assert res.status_code == 403

client = TestClient(app_fixture)
res = client.post(
"/send",
json={"data": "data"},
)
assert res.status_code == 403

def test_authed_endpoints(
self, mocker: MockerFixture, app_fixture: FastAPI, authed_user_mock: Mock
):
email_json = {
"email": {
"subject": "Institution Profile Change",
"body": "lei: 1234567890ABCDEFGHIJ\ninstitution_name_1: Fintech 1\ntin_1: 12-3456789\nrssd_1: 1234567",
"from_addr": "[email protected]",
"sender": "Jane Doe <[email protected]>",
"to": ["[email protected]"],
"cc": None,
"bcc": None,
}
}

mock = mocker.patch("api.send_email")
mock.return_value = {"email": email_json}
client = TestClient(app_fixture)
res = client.get("/")
assert res.status_code == 200

client = TestClient(app_fixture)
res = client.post(
"/send",
json=email_json,
headers={
"X-Mail-Subject": "Institution Profile Change",
"X-Mail-Sender-Address": "[email protected]",
"X-Mail-Sender-Name": "Jane Doe",
},
data={
"lei": "1234567890ABCDEFGHIJ",
"institution_name_1": "Fintech 1",
"tin_1": "12-3456789",
"rssd_1": "1234567",
},
)
assert res.status_code == 200
assert res.json() == email_json
18 changes: 12 additions & 6 deletions tests/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,11 @@ class TestEmailApiSettings:
default_smtp_username = SecretStr("tester_1")
default_smtp_password = SecretStr("ABCDEFGHIJKLMNOPQRSTUVWXYZ")


def test_check_smtp_mock_sender(self):
EmailApiSettings(
email_mailer=EmailMailerType.MOCK,
from_addr=self.default_from_addr,
to=self.default_to,
to=self.default_to,
)

def test_check_smtp_no_host(self):
Expand All @@ -28,7 +27,9 @@ def test_check_smtp_no_host(self):
to=self.default_to,
)

assert "SMTP host must be set when using SMTP email sender" in str(excinfo.value)
assert "SMTP host must be set when using SMTP email sender" in str(
excinfo.value
)

def test_check_smtp_with_host(self):
EmailApiSettings(
Expand Down Expand Up @@ -58,8 +59,10 @@ def test_check_smtp_with_creds_no_username(self):
to=self.default_to,
)

assert "username and password must both be set when using SMTP credentials" in str(excinfo.value)

assert (
"username and password must both be set when using SMTP credentials"
in str(excinfo.value)
)

def test_check_smtp_with_creds_no_password(self):
with pytest.raises(ValidationError) as excinfo:
Expand All @@ -70,4 +73,7 @@ def test_check_smtp_with_creds_no_password(self):
from_addr=self.default_from_addr,
to=self.default_to,
)
assert "username and password must both be set when using SMTP credentials" in str(excinfo.value)
assert (
"username and password must both be set when using SMTP credentials"
in str(excinfo.value)
)