Skip to content

Commit

Permalink
Merge branch 'main' into stage
Browse files Browse the repository at this point in the history
  • Loading branch information
MelissaAutumn committed Dec 7, 2023
2 parents 3fe4dfa + 690eb7e commit 9ad03f6
Show file tree
Hide file tree
Showing 41 changed files with 1,244 additions and 278 deletions.
26 changes: 13 additions & 13 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,6 @@ DB_SECRET=
# Secret phrase for session encryption
SESSION_SECRET=

# -- AUTH0 --
# Management API
AUTH0_API_CLIENT_ID=
AUTH0_API_SECRET=
# Auth API
AUTH0_API_DOMAIN=
AUTH0_API_AUDIENCE=
# Role keys, configurable in Auth0 User Management -> Roles
AUTH0_API_ROLE_ADMIN=
AUTH0_API_ROLE_BASIC=
AUTH0_API_ROLE_PLUS=
AUTH0_API_ROLE_PRO=

# -- MAIL --

# Service email for emails on behalf of Thunderbird Appointment
Expand Down Expand Up @@ -74,6 +61,19 @@ SIGNED_SECRET=
SENTRY_DSN=
# Possible values: prod, dev
APP_ENV=dev
# Possible values: password, fxa
AUTH_SCHEME=password

FXA_OPEN_ID_CONFIG=
FXA_CLIENT_ID=
FXA_SECRET=
FXA_CALLBACK=
FXA_ALLOW_LIST=

# For password auth only!
JWT_SECRET=
JWT_ALGO=HS256
JWT_EXPIRE_IN_MINS=10000

# -- TESTING --
AUTH0_TEST_USER=
Expand Down
25 changes: 12 additions & 13 deletions backend/.env.test
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,6 @@ DB_SECRET=db-secret-pls-ignore
# Secret phrase for session encryption
SESSION_SECRET=session-secret-pls-ignore

# -- AUTH0 --
# Management API
AUTH0_API_CLIENT_ID=
AUTH0_API_SECRET=
# Auth API
AUTH0_API_DOMAIN=
AUTH0_API_AUDIENCE=
# Role keys, configurable in Auth0 User Management -> Roles
AUTH0_API_ROLE_ADMIN=
AUTH0_API_ROLE_BASIC=
AUTH0_API_ROLE_PLUS=
AUTH0_API_ROLE_PRO=

# -- MAIL --

# Service email for emails on behalf of Thunderbird Appointment
Expand Down Expand Up @@ -75,6 +62,18 @@ SIGNED_SECRET=test-secret-pls-ignore
SENTRY_DSN=
# Possible values: prod, dev, test
APP_ENV=test
AUTH_SCHEME=password

FXA_OPEN_ID_CONFIG=
FXA_CLIENT_ID=
FXA_SECRET=
FXA_CALLBACK=
FXA_ALLOW_LIST=

# For password auth only!
JWT_SECRET=test-secret-pls-ignore-2
JWT_ALGO=HS256
JWT_EXPIRE_IN_MINS=10000

# -- TESTING --
AUTH0_TEST_USER=
Expand Down
7 changes: 6 additions & 1 deletion backend/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
alembic==1.9.3
auth0-python==4.0.0
argon2-cffi==23.1.0
argon2-cffi-bindings==21.2.0
caldav==1.0.1
cryptography==39.0.1
fastapi-auth0==0.3.2
fastapi==0.91.0
fastapi==0.104.1
google-api-python-client==2.85.0
google-auth-httplib2==0.1.0
google-auth-oauthlib==1.0.0
Expand All @@ -13,6 +15,9 @@ itsdangerous==2.1.2
mysqlclient==2.1.1
mysql-connector-python==8.0.32
python-dotenv==1.0.0
python-jose==3.3.0
python-multipart==0.0.6
pydantic==2.5.2
sentry-sdk==1.26.0
sqlalchemy-utils==0.39.0
sqlalchemy==1.4.40
Expand Down
2 changes: 1 addition & 1 deletion backend/scripts/dev-entry.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ run-command update-db
python -u -m smtpd -n -c DebuggingServer localhost:8050 &

# Start up real webserver
uvicorn --factory src.appointment.main:server --reload --host 0.0.0.0 --port 8090
uvicorn --factory src.appointment.main:server --reload --host 0.0.0.0 --port 5173
159 changes: 159 additions & 0 deletions backend/src/appointment/controller/apis/fxa_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import json
import logging
import os

from requests_oauthlib import OAuth2Session
import requests
from ...database import models, repo
from ...exceptions.fxa_api import NotInAllowListException


class FxaConfig:
issuer: str
authorization_url: str
metrics_flow_url: str
token_url: str
profile_url: str
destroy_url: str
jwks_url: str

@staticmethod
def from_url(url):
response: dict = requests.get(url).json()

# Check our supported scopes
scopes = response.get('scopes_supported')
if 'profile' not in scopes:
logging.warning("Profile scope not found in supported scopes for fxa!")

config = FxaConfig()
config.issuer = response.get('issuer')
config.authorization_url = response.get('authorization_endpoint')
# Not available from the config endpoint, but it's on the same domain as authorization
config.metrics_flow_url = response.get('authorization_endpoint').replace('authorization', 'metrics-flow')
config.token_url = response.get('token_endpoint')
config.profile_url = response.get('userinfo_endpoint')
config.destroy_url = response.get('revocation_endpoint')
config.jwks_url = response.get('jwks_uri')

return config


class FxaClient:
ENTRYPOINT = 'tbappointment'

SCOPES = [
"profile",
]

config = FxaConfig()

client: OAuth2Session | None = None
subscriber_id: int | None = None

def __init__(self, client_id, client_secret, callback_url):
self.client_id = client_id
self.client_secret = client_secret
self.callback_url = callback_url
self.subscriber_id = None
self.client = None

def setup(self, subscriber_id=None, token=None):
"""Retrieve the openid connect urls, and setup our client connection"""
if type(token) is str:
token = json.loads(token)

self.config = FxaConfig.from_url(os.getenv('FXA_OPEN_ID_CONFIG'))

self.subscriber_id = subscriber_id
self.client = OAuth2Session(self.client_id, redirect_uri=self.callback_url, scope=self.SCOPES,
auto_refresh_url=self.config.token_url,
auto_refresh_kwargs={"client_id": self.client_id, "client_secret": self.client_secret},
token=token,
token_updater=self.token_saver)

def is_in_allow_list(self, email: str):
"""Check this email against our allow list"""
allow_list = os.getenv('FXA_ALLOW_LIST')
# If we have no allow list, then we allow everyone
if not allow_list or allow_list == '':
return True

return email.endswith(tuple(allow_list.split(',')))

def get_redirect_url(self, state, email):
if not self.is_in_allow_list(email):
raise NotInAllowListException()

utm_campaign = f"{self.ENTRYPOINT}_{os.getenv('APP_ENV')}"
utm_source = "login"

try:
response = self.client.get(url=self.config.metrics_flow_url, params={
'entrypoint': self.ENTRYPOINT,
'form_type': 'email',
'utm_campaign': utm_campaign,
'utm_source': utm_source
})

response.raise_for_status()

flow_values = response.json()
except requests.HTTPError as e:
# Not great, but we can still continue along..
logging.error(f"Could not initialize metrics flow, error occurred: {e.response.status_code} - {e.response.text}")
flow_values = {}

url, state = self.client.authorization_url(
self.config.authorization_url,
state=state,
access_type='offline',
entrypoint=self.ENTRYPOINT,
action='email',
# Flow metrics stuff
email=email,
flow_begin_time=flow_values.get('flowBeginTime'),
flow_id=flow_values.get('flowId'),
utm_source=utm_source,
utm_campaign=utm_campaign
)

return url, state

def get_credentials(self, code: str):
return self.client.fetch_token(self.config.token_url, code, client_secret=self.client_secret, include_client_id=True)

def token_saver(self, token):
"""requests-oauth automagically calls this function when it has a new refresh token for us.
This makes it a bit awkward but we make it work..."""
from appointment.dependencies.database import get_db

self.client.token = token

# Need a subscriber attached to this request in order to save a token
if self.subscriber_id is None:
return

repo.update_subscriber_external_connection_token(get_db(), json.dumps(token), self.subscriber_id, models.ExternalConnectionType.fxa)

def get_profile(self):
"""Retrieve the user's profile information"""
return self.client.get(url=self.config.profile_url).json()

def logout(self):
"""Invalidate the current refresh token"""
# I assume a refresh token will destroy its access tokens
refresh_token = self.client.token.get('refresh_token')

# This route doesn't want auth! (Because we're destroying it)
resp = requests.post(self.config.destroy_url, json={
'refresh_token': refresh_token,
'client_id': self.client_id,
'client_secret': self.client_secret
})

resp.raise_for_status()
return resp

def get_jwk(self):
return requests.get(self.config.jwks_url).json()
7 changes: 6 additions & 1 deletion backend/src/appointment/database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ class DayOfWeek(enum.Enum):
class ExternalConnectionType(enum.Enum):
zoom = 1
google = 2
fxa = 3


class MeetingLinkProviderType(enum.StrEnum):
Expand All @@ -77,10 +78,14 @@ class Subscriber(Base):

id = Column(Integer, primary_key=True, index=True)
username = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), unique=True, index=True)
# Encrypted (here) and hashed (by the associated hashing functions in routes/auth)
password = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=False)
email = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), unique=True, index=True)
name = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True)
level = Column(Enum(SubscriberLevel), default=SubscriberLevel.basic, index=True)
timezone = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True)
avatar_url = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=2048), index=False)

google_tkn = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=2048), index=False)
# Temp storage for verifying google state tokens between authentication
google_state = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=512), index=False)
Expand All @@ -91,7 +96,7 @@ class Subscriber(Base):
slots = relationship("Slot", cascade="all,delete", back_populates="subscriber")
external_connections = relationship("ExternalConnections", cascade="all,delete", back_populates="owner")

def get_external_connection(self, type: ExternalConnectionType):
def get_external_connection(self, type: ExternalConnectionType) -> 'ExternalConnections':
"""Retrieves the first found external connection by type or returns None if not found"""
return next(filter(lambda ec: ec.type == type, self.external_connections), None)

Expand Down
13 changes: 13 additions & 0 deletions backend/src/appointment/database/repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -621,3 +621,16 @@ def get_external_connections_by_type(db: Session, subscriber_id: int, type: mode
result = query.all()

return result


def get_subscriber_by_fxa_uid(db: Session, type_id: str):
"""Return a subscriber from a fxa profile uid"""
query = (
db.query(models.ExternalConnections)
.filter(models.ExternalConnections.type == models.ExternalConnectionType.fxa)
.filter(models.ExternalConnections.type_id == type_id)
)

result = query.first()

return result
Loading

0 comments on commit 9ad03f6

Please sign in to comment.