Skip to content

Commit 361b3d4

Browse files
author
EC2 Default User
committed
Integrate JWT Auth into API
partially fixes #2063 This will allow us to add permissions checks to our API calls. Currently it does not require a valid JWT, but if one exists will store the "sub" field such that the user's id can be checked against our user table to establish permissions.
1 parent a0b418d commit 361b3d4

File tree

6 files changed

+101
-1
lines changed

6 files changed

+101
-1
lines changed

changelog_entry.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
- bump: patch
22
changes:
33
changed:
4-
- Updated PolicyEngine US to 1.168.1.
4+
- API now checks for authenticated user, but only prints access errors rather than failing.

policyengine_api/api.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@
77
import flask
88
import yaml
99
from flask_caching import Cache
10+
from authlib.integrations.flask_oauth2 import ResourceProtector
11+
from policyengine_api.validator import Auth0JWTBearerTokenValidator
1012
from policyengine_api.utils import make_cache_key
1113
from .constants import VERSION
14+
import policyengine_api.auth_context as auth_context
1215

1316
# from werkzeug.middleware.profiler import ProfilerMiddleware
1417

@@ -40,6 +43,13 @@
4043

4144
app = application = flask.Flask(__name__)
4245

46+
## as per https://auth0.com/docs/quickstart/backend/python/interactive
47+
require_auth = ResourceProtector()
48+
validator = Auth0JWTBearerTokenValidator()
49+
require_auth.register_token_validator(validator)
50+
51+
auth_context.configure(app, require_auth=require_auth)
52+
4353
app.config.from_mapping(
4454
{
4555
"CACHE_TYPE": "RedisCache",

policyengine_api/auth_context.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from flask import Flask, g
2+
from werkzeug.local import LocalProxy
3+
from authlib.integrations.flask_oauth2 import ResourceProtector
4+
5+
6+
def configure(app: Flask, require_auth: ResourceProtector):
7+
"""
8+
Configure the application to attempt to get and validate a bearer token.
9+
If there is a token and it's valid the user id is added to the request context
10+
which can be accessed via get_user_id
11+
Otherwise, the request is accepted but get_user_id returns None
12+
13+
This supports our current auth model where only user-specific actions are restricted and
14+
then only to allow the user
15+
"""
16+
17+
# If the user is authenticated then get the user id from the token
18+
# And add it to the flask request context.
19+
@app.before_request
20+
def get_user():
21+
try:
22+
token = require_auth.acquire_token()
23+
print(f"Validated JWT for sub {g.authlib_server_oauth2_token.sub}")
24+
except Exception as ex:
25+
print(f"Unable to parse a valid bearer token from request: {ex}")
26+
27+
28+
def get_user() -> None | str:
29+
# I didn't see this documented anywhere, but if you look at the source code
30+
# the validator stores the token in the flask global context under this name.
31+
if "authlib_server_oauth2_token" not in g:
32+
print(
33+
"authlib_server_oauth2_token is not in the flask global context. Please make sure you called 'configure' on the app"
34+
)
35+
return None
36+
if "sub" not in g.authlib_server_oauth2_token:
37+
print(
38+
"ERROR: authlib_server_oauth2_token does not contain a sub field. The JWT validator should force this to be true."
39+
)
40+
return None
41+
return g.authlib_server_oauth2_token.sub

policyengine_api/routes/user_profile_routes.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from flask import Blueprint, Response, request
2+
from policyengine_api.auth_context import get_user
23
from policyengine_api.utils.payload_validators import validate_country
34
from policyengine_api.data import database
45
import json
@@ -9,6 +10,20 @@
910
user_service = UserService()
1011

1112

13+
#TODO: This does nothing pending refresh of user tokens
14+
#to include auth information. Once that happens this will throw
15+
# a 403 unauthorized exception if the authenticated user does
16+
# not match
17+
def assert_auth_user_is(user_id:str):
18+
current_user = get_user()
19+
if current_user is None:
20+
print("ERROR: No user is logged in. Ignoring.")
21+
if current_user != user_id:
22+
print(f"ERROR: Request is autheticated as {current_user} not expected user {user_id}")
23+
return
24+
25+
26+
1227
@user_profile_bp.route("/<country_id>/user-profile", methods=["POST"])
1328
@validate_country
1429
def set_user_profile(country_id: str) -> Response:
@@ -24,6 +39,8 @@ def set_user_profile(country_id: str) -> Response:
2439
username = payload.pop("username", None)
2540
user_since = payload.pop("user_since")
2641

42+
assert_auth_user_is(auth0_id)
43+
2744
created, row = user_service.create_profile(
2845
primary_country=country_id,
2946
auth0_id=auth0_id,
@@ -112,6 +129,11 @@ def update_user_profile(country_id: str) -> Response:
112129
if user_id is None:
113130
raise BadRequest("Payload must include user_id")
114131

132+
current = user_service.get_profile(user_id=user_id)
133+
if current is None:
134+
raise NotFound("No such user id")
135+
assert_auth_user_is(current.auth0_id)
136+
115137
updated = user_service.update_profile(
116138
user_id=user_id,
117139
primary_country=primary_country,

policyengine_api/validator.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# As defined by https://auth0.com/docs/quickstart/backend/python/interactive
2+
import json
3+
from urllib.request import urlopen
4+
5+
from authlib.oauth2.rfc7523 import JWTBearerTokenValidator
6+
from authlib.jose.rfc7517.jwk import JsonWebKey
7+
8+
9+
class Auth0JWTBearerTokenValidator(JWTBearerTokenValidator):
10+
def __init__(
11+
self,
12+
audience="https://api.policyengine.org/",
13+
):
14+
issuer = "https://policyengine.uk.auth0.com/"
15+
jsonurl = urlopen(
16+
f"https://policyengine.uk.auth0.com/.well-known/jwks.json"
17+
)
18+
public_key = JsonWebKey.import_key_set(json.loads(jsonurl.read()))
19+
super(Auth0JWTBearerTokenValidator, self).__init__(public_key)
20+
self.claims_options = {
21+
"exp": {"essential": True},
22+
"aud": {"essential": True, "value": audience},
23+
"iss": {"essential": True, "value": issuer},
24+
# Provides the user id as we currently use it.
25+
"sub": {"essential": True},
26+
}

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"streamlit",
3333
"werkzeug",
3434
"Flask-Caching>=2,<3",
35+
"Authlib",
3536
],
3637
extras_require={
3738
"dev": [

0 commit comments

Comments
 (0)