Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
79 commits
Select commit Hold shift + click to select a range
e8d18d4
Reproduce IntegrityError on terms_accepted_user
jwhitlock Dec 11, 2024
f44f288
Refactor Mozilla Account introspect, profile setup
jwhitlock Dec 11, 2024
1b24762
Extract to _create_socialaccount_from_bearer_token
jwhitlock Dec 11, 2024
3fcf8e8
Extract _get_fxa_profile_from_bearer_token
jwhitlock Dec 11, 2024
7c72c1e
Look for matching SA after FxA profile fetch
jwhitlock Dec 12, 2024
5d6194d
Skip coverage for belt-and-suspender code
jwhitlock Dec 12, 2024
2eaecf1
Skip auth check before logging out user
jwhitlock Dec 12, 2024
c7f1711
Add request timeout tests
jwhitlock Dec 12, 2024
4db8fa4
Handle profile timeout
jwhitlock Dec 12, 2024
6d2d63f
Re-raise introspect timeout
jwhitlock Dec 13, 2024
b336b08
Reimplement FxaTokenAuthentication
jwhitlock Dec 13, 2024
9f68e9b
Fix cache key function
jwhitlock Dec 13, 2024
ec6d537
Change setup_fxa_introspect to return FxA data
jwhitlock Dec 16, 2024
3192e43
Change cached key from "json" to "data"
jwhitlock Dec 16, 2024
e08a8b1
Move types, data is always a dict
jwhitlock Dec 16, 2024
5ea1adc
Cache introspect result once
jwhitlock Dec 16, 2024
d3c5dc7
Cache introspect response if called
jwhitlock Dec 16, 2024
ddb8c05
Reimplement introspection errors and cache
jwhitlock Dec 17, 2024
e4e2a6f
Tune name, docstrings for mocks
jwhitlock Dec 18, 2024
ea44f04
Add tests for failed IntrospectionResponse init
jwhitlock Dec 18, 2024
37148fd
Drop v=1 in cache data, YNGNI
jwhitlock Dec 18, 2024
f83eec1
Refactor IntrospectionError.raiseException
jwhitlock Dec 19, 2024
59bb34d
Refactor __repr__, add tests
jwhitlock Dec 19, 2024
1c64046
Test IntrospectionError.__eq__
jwhitlock Dec 19, 2024
5724b07
Use setup_fxa_introspect for all introspect tests
jwhitlock Dec 19, 2024
f8ab152
Convert introspect_token tests to pytest
jwhitlock Dec 19, 2024
0d6bd9a
Add pytest fixture for cache
jwhitlock Dec 19, 2024
7c5aa49
IntrospectionError.save_to_cache tests
jwhitlock Dec 19, 2024
8e51773
Add tests for load_introspection_result_from_cache
jwhitlock Dec 19, 2024
db4c21f
Convert introspect_token_or_raise tests
jwhitlock Dec 19, 2024
316baa9
Ignore typing's assert_never()
jwhitlock Dec 19, 2024
0fcd981
Convert FxaTokenAuthentication tests to pytest
jwhitlock Dec 19, 2024
a1bb7ee
Add variant where Relay user is optional
jwhitlock Dec 19, 2024
8b6389b
Use auth class for terms_acccepted_user
jwhitlock Dec 19, 2024
3779aa3
Pass the token to functions that use the cache
jwhitlock Dec 19, 2024
bf781a7
Add .as_cache_value()
jwhitlock Dec 20, 2024
afc6a79
Add type hints
jwhitlock Dec 20, 2024
3029928
Use .as_cache_value in auth tests
jwhitlock Dec 20, 2024
6869ed1
Use .as_cache_value in view tests
jwhitlock Dec 20, 2024
ddbe64a
Fix assert_called_once_with
jwhitlock Dec 20, 2024
db00dad
Fix assert_called_once_with, again
jwhitlock Dec 20, 2024
b003d18
Add links to platform docs
jwhitlock Jan 9, 2025
07f8e0a
Use hash of token as cache key
jwhitlock Jan 9, 2025
ed24da7
Fix typo
jwhitlock Jan 9, 2025
42249bf
Add token to IntrospectionReponse/Error
jwhitlock Jan 10, 2025
4224055
Add HasValidFxaToken permission
jwhitlock Jan 10, 2025
01c3ca6
Remove FxaTokenAuthenticationRelayUserOptional
jwhitlock Jan 10, 2025
b3f104d
Remove permission checks from auth
jwhitlock Jan 10, 2025
3d4bb17
Add request_s timer to introspect results
jwhitlock Jan 14, 2025
20ced7e
Capture introspection request time
jwhitlock Jan 14, 2025
5c1cad9
Log introspection time
jwhitlock Jan 14, 2025
949fba7
Log profile fetch time
jwhitlock Jan 14, 2025
b6c3c99
Update comments
jwhitlock Jan 14, 2025
b446ae5
Revert to nested if
jwhitlock Jan 14, 2025
77082d9
Emit timing metric for introspect errors
jwhitlock Jan 14, 2025
3983cdd
Emit timing metric for introspect success
jwhitlock Jan 14, 2025
e5b2e11
Update docstring
jwhitlock Jan 14, 2025
66fdab9
Update docstring
jwhitlock Jan 14, 2025
03e36f1
Rearrange _get_fxa_profile_from_bearer_token
jwhitlock Jan 16, 2025
4fce545
Change _get_fxa_profile_from_bearer_tokeni return
jwhitlock Jan 16, 2025
f441c79
Emit timing metric for profile fetch
jwhitlock Jan 16, 2025
1bd6146
Add IntrospectionResponse.is_expired
jwhitlock Jan 16, 2025
b191ebc
Check token expiration
jwhitlock Jan 17, 2025
a07e19a
Add env FXA_TOKEN_AUTH_VERSION
jwhitlock Jan 17, 2025
f6099b6
Add return code for TokenExpired
jwhitlock Jan 18, 2025
cfdbeba
Be less specific about message
jwhitlock Nov 14, 2025
fec4a1d
Fix spelling
jwhitlock Nov 17, 2025
da6f636
Include all params in repr, even if default
jwhitlock Nov 18, 2025
79aecc8
Ensure shlex.quote only gets strings
jwhitlock Nov 18, 2025
624f00f
Encode FxA data as base64
jwhitlock Nov 18, 2025
ef5bfe7
Encode FxA non-JSON data as base64
jwhitlock Nov 18, 2025
c6d496f
Encode FxA JSON non-dict data as base64
jwhitlock Nov 18, 2025
12b9be5
Move FxA token grace period to settings
jwhitlock Nov 18, 2025
3bad23d
If FxA omits or changes exp, log and use default
jwhitlock Nov 18, 2025
671c4db
Attempt to continue on non-200 from introspect
jwhitlock Nov 18, 2025
3b8ec41
Update TermsAcceptedUserViewTest for new errors
jwhitlock Nov 18, 2025
c52d32a
Update existing code (2024 version) from main
jwhitlock Nov 26, 2025
a566b7a
Rename to x_2025
jwhitlock Nov 26, 2025
ce6ae11
Refactor existing / new implementations, bump year
jwhitlock Nov 26, 2025
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
693 changes: 562 additions & 131 deletions api/authentication.py

Large diffs are not rendered by default.

166 changes: 166 additions & 0 deletions api/authentication_2025.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import logging
import shlex
from datetime import UTC, datetime
from typing import Any

from django.conf import settings
from django.core.cache import cache

import requests
from allauth.socialaccount.models import SocialAccount
from rest_framework.authentication import BaseAuthentication, get_authorization_header
from rest_framework.exceptions import (
APIException,
AuthenticationFailed,
NotFound,
ParseError,
PermissionDenied,
)

logger = logging.getLogger("events")
INTROSPECT_TOKEN_URL = "{}/introspect".format(
settings.SOCIALACCOUNT_PROVIDERS["fxa"]["OAUTH_ENDPOINT"]
)


def get_cache_key(token):
return hash(token)


def introspect_token(token: str) -> dict[str, Any]:
try:
fxa_resp = requests.post(
INTROSPECT_TOKEN_URL,
json={"token": token},
timeout=settings.FXA_REQUESTS_TIMEOUT_SECONDS,
)
except Exception as exc:
logger.error(
"Could not introspect token with FXA.",
extra={"error_cls": type(exc), "error": shlex.quote(str(exc))},
)
raise AuthenticationFailed("Could not introspect token with FXA.")

fxa_resp_data = {"status_code": fxa_resp.status_code, "json": {}}
try:
fxa_resp_data["json"] = fxa_resp.json()
except requests.exceptions.JSONDecodeError:
logger.error(
"JSONDecodeError from FXA introspect response.",
extra={"fxa_response": shlex.quote(fxa_resp.text)},
)
raise AuthenticationFailed("JSONDecodeError from FXA introspect response")
return fxa_resp_data


def get_fxa_uid_from_oauth_token(token: str, use_cache: bool = True) -> str:
# set a default cache_timeout, but this will be overridden to match
# the 'exp' time in the JWT returned by FxA
cache_timeout = 60
cache_key = get_cache_key(token)

if not use_cache:
fxa_resp_data = introspect_token(token)
else:
# set a default fxa_resp_data, so any error during introspection
# will still cache for at least cache_timeout to prevent an outage
# from causing useless run-away repetitive introspection requests
fxa_resp_data = {"status_code": None, "json": {}}
try:
cached_fxa_resp_data = cache.get(cache_key)

if cached_fxa_resp_data:
fxa_resp_data = cached_fxa_resp_data
else:
# no cached data, get new
fxa_resp_data = introspect_token(token)
except AuthenticationFailed:
raise
finally:
# Store potential valid response, errors, inactive users, etc. from FxA
# for at least 60 seconds. Valid access_token cache extended after checking.
cache.set(cache_key, fxa_resp_data, cache_timeout)

if fxa_resp_data["status_code"] is None:
raise APIException("Previous FXA call failed, wait to retry.")

if not fxa_resp_data["status_code"] == 200:
raise APIException("Did not receive a 200 response from FXA.")

if not fxa_resp_data["json"].get("active"):
raise AuthenticationFailed("FXA returned active: False for token.")

# FxA user is active, check for the associated Relay account
if (raw_fxa_uid := fxa_resp_data.get("json", {}).get("sub")) is None:
raise NotFound("FXA did not return an FXA UID.")
fxa_uid = str(raw_fxa_uid)

scopes = fxa_resp_data.get("json", {}).get("scope", "").split()
if settings.RELAY_SCOPE not in scopes:
raise AuthenticationFailed(
"FXA token is missing scope: https://identity.mozilla.com/apps/relay."
)

# cache valid access_token and fxa_resp_data until access_token expiration
# TODO: revisit this since the token can expire before its time
if isinstance(fxa_resp_data.get("json", {}).get("exp"), int):
# Note: FXA iat and exp are timestamps in *milliseconds*
fxa_token_exp_time = int(fxa_resp_data["json"]["exp"] / 1000)
now_time = int(datetime.now(UTC).timestamp())
fxa_token_exp_cache_timeout = fxa_token_exp_time - now_time
if fxa_token_exp_cache_timeout > cache_timeout:
# cache until access_token expires (matched Relay user)
# this handles cases where the token already expired
cache_timeout = fxa_token_exp_cache_timeout
cache.set(cache_key, fxa_resp_data, cache_timeout)

return fxa_uid


class FxaTokenAuthentication(BaseAuthentication):
def authenticate_header(self, request):
# Note: we need to implement this function to make DRF return a 401 status code
# when we raise AuthenticationFailed, rather than a 403. See:
# https://www.django-rest-framework.org/api-guide/authentication/#custom-authentication
return "Bearer"

def authenticate(self, request):
authorization = get_authorization_header(request).decode()
if not authorization or not authorization.startswith("Bearer "):
# If the request has no Bearer token, return None to attempt the next
# auth scheme in the REST_FRAMEWORK AUTHENTICATION_CLASSES list
return None

token = authorization.split(" ")[1]
if token == "":
raise ParseError("Missing FXA Token after 'Bearer'.")

use_cache = True
method = request.method
if method in ["POST", "DELETE", "PUT"]:
use_cache = False
if method == "POST" and request.path == "/api/v1/relayaddresses/":
use_cache = True
fxa_uid = get_fxa_uid_from_oauth_token(token, use_cache)
try:
# MPP-3021: select_related user object to save DB query
sa = SocialAccount.objects.filter(
uid=fxa_uid, provider="fxa"
).select_related("user")[0]
except IndexError:
raise PermissionDenied(
"Authenticated user does not have a Relay account."
" Have they accepted the terms?"
)
user = sa.user

if not user.is_active:
raise PermissionDenied(
"Authenticated user does not have an active Relay account."
" Have they been deactivated?"
)

if user:
return (user, token)
else:
raise NotFound()
17 changes: 17 additions & 0 deletions api/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from rest_framework.views import APIView
from waffle import flag_is_active

from .authentication import IntrospectionResponse

READ_METHODS = ["GET", "HEAD"]


Expand Down Expand Up @@ -40,3 +42,18 @@ def has_permission(self, request, view):
return flag_is_active(request, "manage_flags") and request.user.email.endswith(
"@mozilla.com"
)


class HasValidFxaToken(permissions.BasePermission):
def has_permission(self, request: Request, view: APIView) -> bool:
return isinstance(request.auth, IntrospectionResponse)


class IsActive(permissions.BasePermission):
def has_permission(self, request: Request, view: APIView) -> bool:
return isinstance(request.user, User) and request.user.is_active


class IsNewUser(permissions.BasePermission):
def has_permission(self, request: Request, view: APIView) -> bool:
return isinstance(request.user, AnonymousUser)
Loading