Skip to content

Commit 371bb04

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. In the user profile api calls we will check user permission, but not yet fail (just log) if the constraint is not met.
1 parent a0b418d commit 371bb04

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)