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

[MCC-961880] Add MAuthASGIMiddleware #34

Merged
merged 38 commits into from
Sep 8, 2022
Merged
Show file tree
Hide file tree
Changes from 36 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
72ea924
add fastapi authenticator [skip ci]
ejinotti-mdsol Aug 25, 2022
a3cb337
use similar pattern to flask authenticator [skip ci]
ejinotti-mdsol Aug 25, 2022
8e19c2f
add tests
ejinotti-mdsol Aug 26, 2022
6cf5ab8
update readme
ejinotti-mdsol Aug 26, 2022
b570bdf
add some formatting
ejinotti-mdsol Aug 26, 2022
8971405
update contributing and changelog
ejinotti-mdsol Aug 26, 2022
e7b1c9c
readme fix [skip ci]
ejinotti-mdsol Aug 26, 2022
de20288
use async kw
ejinotti-mdsol Aug 26, 2022
151fc4a
remove python 3.6, add 3.10
ejinotti-mdsol Aug 26, 2022
60db262
update changelog
ejinotti-mdsol Aug 26, 2022
de00c7f
upgrade pytest for 3.10 compat
ejinotti-mdsol Aug 26, 2022
ee79eda
some dependency hell fixes
ejinotti-mdsol Aug 26, 2022
0066550
first pass at generic asgi middleware [skip ci]
ejinotti-mdsol Sep 1, 2022
1fa62d0
missed an await [skip ci]
ejinotti-mdsol Sep 1, 2022
a3e95dd
send response instead of raising [skip ci]
ejinotti-mdsol Sep 1, 2022
15dcdd6
better decoding, some initial tests [skip ci]
ejinotti-mdsol Sep 1, 2022
fdaa43a
update contrib [skip ci]
ejinotti-mdsol Sep 1, 2022
aeb4ba2
reorganize tests [skip ci]
ejinotti-mdsol Sep 1, 2022
1041277
rest of tests
ejinotti-mdsol Sep 2, 2022
e41271f
update readme
ejinotti-mdsol Sep 2, 2022
028f38f
update changelog
ejinotti-mdsol Sep 2, 2022
d401ba1
try 3.10.6 tiny version
ejinotti-mdsol Sep 2, 2022
5ae7dc6
try jammy
ejinotti-mdsol Sep 2, 2022
d7c5801
try older poetry version
ejinotti-mdsol Sep 2, 2022
ad3617e
add comment to travis file [skip ci]
ejinotti-mdsol Sep 2, 2022
2c96be3
poetry 1.1.15 in travis
ejinotti-mdsol Sep 2, 2022
768904d
go with same context from other clients
ejinotti-mdsol Sep 2, 2022
905f455
update readme example [skip ci]
ejinotti-mdsol Sep 2, 2022
739f897
add exempt option
ejinotti-mdsol Sep 2, 2022
5501fa4
forgot to decode query string
ejinotti-mdsol Sep 2, 2022
a630160
only decode query string if present
ejinotti-mdsol Sep 2, 2022
61f7889
style nit
ejinotti-mdsol Sep 2, 2022
78ccfe2
wrap in list to match ruby response
ejinotti-mdsol Sep 2, 2022
6bc5a4c
epic hack to allow downstream receiving
ejinotti-mdsol Sep 2, 2022
15f04b7
keep body as binary
ejinotti-mdsol Sep 6, 2022
0e9c0b2
ignore non-http request types
ejinotti-mdsol Sep 6, 2022
c1f9bc7
make copy of exempt arg and raise type error if not a set
ejinotti-mdsol Sep 7, 2022
f74cce4
use optional type hint
ejinotti-mdsol Sep 7, 2022
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
8 changes: 4 additions & 4 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ language: python
cache: pip

python:
- 3.6
- 3.7.13 # specify micro version to avoid having EnvCommandError
- 3.8
- 3.9
ejinotti-mdsol marked this conversation as resolved.
Show resolved Hide resolved
- 3.10

before_install:
- pip install poetry
- pip install poetry==1.1.15 # latest poetry 1.1.20 was having problems with travis
- pip install tox-travis

install: poetry install -v
Expand All @@ -25,12 +25,12 @@ stages:
jobs:
include:
- stage: lint
python: 3.8
python: 3.10
script:
- poetry run flake8 --version
- poetry run flake8
- stage: publish
python: 3.8
python: 3.10
script: skip
before_deploy:
- poetry config pypi-token.pypi $POETRY_PYPI_TOKEN_PYPI # this may be unnecessary
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# 1.3.0
- Add `MAuthASGIMiddleware` for authenticating requests in ASGI frameworks like FastAPI.
- Remove Support for EOL Python 3.6

# 1.2.3
- Ignore `boto3` import error (`ModuleNotFoundError`).

Expand Down
26 changes: 8 additions & 18 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,25 +14,14 @@ To setup your environment:
brew update
brew install pyenv
```
1. Install Pyenv versions for the Tox Suite
1. Install your favorite Python version (>= 3.8 please!)
```bash
pyenv install 3.5.8
ejinotti-mdsol marked this conversation as resolved.
Show resolved Hide resolved
pyenv install 3.6.10
pyenv install 3.7.7
pyenv install 3.8.2
pyenv install pypy3.6-7.3.1
pyenv install <YOUR_FAVORITE_VERSION>
```
1. Install Poetry
1. Install Poetry, see: https://python-poetry.org/docs/#installation
1. Install Dependencies
```bash
curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python
```
1. Install Tox
```bash
pip install tox
```
1. Setup the local project versions (one for each env in the `envlist`)
```bash
pyenv local 3.5.8 3.6.10 3.7.7 3.8.2 pypy3.6-7.1.1
poetry install -v
ejinotti-mdsol marked this conversation as resolved.
Show resolved Hide resolved
```


Expand All @@ -54,5 +43,6 @@ to init the submodule.

## Unit Tests

1. Make any changes, update the tests and then run tests with `tox`
1. Coverage report can be viewed using `open htmlcov/index.html`
1. Make any changes, update the tests and then run tests with `poetry run tox`.
1. Coverage report can be viewed using `open htmlcov/index.html`.
1. Or if you don't care about tox, just run `poetry run pytest` or `poetry run pytest <SOME_FILE>`.
44 changes: 35 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,22 +114,16 @@ app_uuid = authenticator.get_app_uuid()

#### Flask applications

You will need to create an application instance and initialize it with FlaskAuthenticator:
You will need to create an application instance and initialize it with `FlaskAuthenticator`.
To specify routes that need to be authenticated use the `requires_authentication` decorator.

```python
from flask import Flask
from mauth_client.flask_authenticator import FlaskAuthenticator
from mauth_client.flask_authenticator import FlaskAuthenticator, requires_authentication

app = Flask("Some Sample App")
authenticator = FlaskAuthenticator()
authenticator.init_app(app)
```

To specify routes that need to be authenticated use the `requires_authentication` decorator:

```python
from flask import Flask
from mauth_client.flask_authenticator import requires_authentication

@app.route("/some/private/route", methods=["GET"])
@requires_authentication
Expand All @@ -141,6 +135,38 @@ def app_status():
return "OK"
```

#### ASGI applications

To apply to an ASGI application you should use the `MAuthASGIMiddleware`. You
can make certain paths exempt from authentication by passing the `exempt`
option with a set of paths to exempt.

Here is an example for FastAPI. Note that requesting app's UUID and the
protocol version will be added to the ASGI `scope` for successfully
authenticated requests.

```python
from fastapi import FastAPI, Request
from mauth_client.constants import ENV_APP_UUID, ENV_PROTOCOL_VERSION
from mauth_client.middlewares import MAuthASGIMiddleware

app = FastAPI()
app.add_middleware(MAuthASGIMiddleware, exempt={"/app_status"})

@app.get("/")
async def root(request: Request):
return {
"msg": "authenticated",
"app_uuid": request.scope[ENV_APP_UUID],
"protocol_version": request.scope[ENV_PROTOCOL_VERSION],
}

@app.get("/app_status")
async def app_status():
return {
"msg": "this route is exempt from authentication",
}
```

## Contributing

Expand Down
4 changes: 4 additions & 0 deletions mauth_client/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,7 @@
MCC_AUTH = "MCC-Authentication"
MCC_TIME = "MCC-Time"
MWSV2_AUTH_PATTERN = re.compile(r"({}) ([^:]+):([^;]+){}".format(MWSV2_TOKEN, AUTH_HEADER_DELIMITER))

ENV_APP_UUID = "mauth.app_uuid"
ENV_AUTHENTIC = "mauth.authentic"
ENV_PROTOCOL_VERSION = "mauth.protocol_version"
1 change: 1 addition & 0 deletions mauth_client/middlewares/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .asgi import MAuthASGIMiddleware
115 changes: 115 additions & 0 deletions mauth_client/middlewares/asgi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import json
import logging

from asgiref.typing import (
ASGI3Application,
ASGIReceiveCallable,
ASGIReceiveEvent,
ASGISendCallable,
Scope,
)
from typing import List, Tuple

from mauth_client.authenticator import LocalAuthenticator
from mauth_client.config import Config
from mauth_client.consts import (
ENV_APP_UUID,
ENV_AUTHENTIC,
ENV_PROTOCOL_VERSION,
)
from mauth_client.signable import RequestSignable
from mauth_client.signed import Signed
from mauth_client.utils import decode

logger = logging.getLogger("mauth_asgi")


class MAuthASGIMiddleware:
def __init__(self, app: ASGI3Application, exempt: set = set()) -> None:
self._validate_configs()
self.app = app
self.exempt = exempt
Copy link
Member

Choose a reason for hiding this comment

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

Do we want to keep a reference or make a shallow copy of the set? As is, if a set is passed in, changes to that set will (potentially accidentally) change this exemption set in the middleware, which feels like it might be surprising behavior.

Suggested change
self.exempt = exempt
self.exempt = exempt.copy()

(Also not sure what best practices are, but I typically like to set the argument to None and set the default here, since setting it in the arg list won't prevent someone from passing None in. e.g.

    def __init__(self, app: ASGI3Application, exempt: set = None) -> None:
        self._validate_configs()
        self.app = app
        self.exempt = exempt.copy() if exempt else set()

)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

for making a copy, yea good idea. will do.

for None as the default.. hmm does that mean we should change the typing notation to Union[set, None]?? theoretically the type hints should prevent them from passing a non-set for that arg. how about a compromise: raise TypeError if it's not a set?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

actually nevermind the compromise, i see the proper way to do type hints when using None default is to use Optional:

https://stackoverflow.com/a/49724148


async def __call__(
self, scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable
) -> None:
path = scope["path"]

if scope["type"] != "http" or path in self.exempt:
return await self.app(scope, receive, send)
danielloganking marked this conversation as resolved.
Show resolved Hide resolved

query_string = scope["query_string"]
url = f"{path}?{decode(query_string)}" if query_string else path
headers = {decode(k): decode(v) for k, v in scope["headers"]}

events, body = await self._get_body(receive)

signable = RequestSignable(
method=scope["method"],
url=url,
body=body,
)
signed = Signed.from_headers(headers)
authenticator = LocalAuthenticator(signable, signed, logger)
is_authentic, status, message = authenticator.is_authentic()

if is_authentic:
# asgi spec calls for passing a copy of the scope rather than mutating it
# note: deepcopy will blow up with infi recursion due to objects in some values
scope_copy = scope.copy()
scope_copy[ENV_APP_UUID] = signed.app_uuid
scope_copy[ENV_AUTHENTIC] = True
scope_copy[ENV_PROTOCOL_VERSION] = signed.protocol_version()
await self.app(scope_copy, self._fake_receive(events), send)
else:
await self._send_response(send, status, message)

def _validate_configs(self) -> None:
# Validate the client settings (APP_UUID, PRIVATE_KEY)
if not all([Config.APP_UUID, Config.PRIVATE_KEY]):
raise TypeError("MAuthASGIMiddleware requires APP_UUID and PRIVATE_KEY")
# Validate the mauth settings (MAUTH_BASE_URL, MAUTH_API_VERSION)
if not all([Config.MAUTH_URL, Config.MAUTH_API_VERSION]):
raise TypeError("MAuthASGIMiddleware requires MAUTH_URL and MAUTH_API_VERSION")
danielloganking marked this conversation as resolved.
Show resolved Hide resolved

async def _get_body(
self, receive: ASGIReceiveCallable
) -> Tuple[List[ASGIReceiveEvent], bytes]:
body = b""
more_body = True
events = []

while more_body:
event = await receive()
body += event.get("body", b"")
more_body = event.get("more_body", False)
events.append(event)
return (events, body)

async def _send_response(self, send: ASGISendCallable, status: int, msg: str) -> None:
await send({
"type": "http.response.start",
"status": status,
"headers": [(b"content-type", b"application/json")],
})
body = {"errors": {"mauth": [msg]}}
await send({
"type": "http.response.body",
"body": json.dumps(body).encode("utf-8"),
})

def _fake_receive(self, events: List[ASGIReceiveEvent]) -> ASGIReceiveCallable:
"""
Create a fake, async receive function using an iterator of the events
we've already read. This will be passed to downstream middlewares/apps
instead of the usual receive fn, so that they can also "receive" the
body events.
"""
events_iter = iter(events)

async def _receive() -> ASGIReceiveEvent:
try:
return next(events_iter)
except StopIteration:
pass
return _receive
danielloganking marked this conversation as resolved.
Show resolved Hide resolved
12 changes: 12 additions & 0 deletions mauth_client/utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import base64
import cchardet
from hashlib import sha512


Expand All @@ -20,3 +21,14 @@ def hexdigest(val):

def base64_encode(signature):
return base64.b64encode(signature).decode("US-ASCII").replace("\n", "")


def decode(byte_string: bytes) -> str:
"""
Attempt to decode a byte string with utf and fallback to cchardet.
"""
try:
return byte_string.decode("utf-8")
except UnicodeDecodeError:
encoding = cchardet.detect(byte_string)["encoding"]
return byte_string.decode(encoding)
danielloganking marked this conversation as resolved.
Show resolved Hide resolved
Loading