-
Notifications
You must be signed in to change notification settings - Fork 245
fix(relay): Create alternate bearer token auth for FxA (MPP-3505) #6049
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
Closed
Closed
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 f44f288
Refactor Mozilla Account introspect, profile setup
jwhitlock 1b24762
Extract to _create_socialaccount_from_bearer_token
jwhitlock 3fcf8e8
Extract _get_fxa_profile_from_bearer_token
jwhitlock 7c72c1e
Look for matching SA after FxA profile fetch
jwhitlock 5d6194d
Skip coverage for belt-and-suspender code
jwhitlock 2eaecf1
Skip auth check before logging out user
jwhitlock c7f1711
Add request timeout tests
jwhitlock 4db8fa4
Handle profile timeout
jwhitlock 6d2d63f
Re-raise introspect timeout
jwhitlock b336b08
Reimplement FxaTokenAuthentication
jwhitlock 9f68e9b
Fix cache key function
jwhitlock ec6d537
Change setup_fxa_introspect to return FxA data
jwhitlock 3192e43
Change cached key from "json" to "data"
jwhitlock e08a8b1
Move types, data is always a dict
jwhitlock 5ea1adc
Cache introspect result once
jwhitlock d3c5dc7
Cache introspect response if called
jwhitlock ddb8c05
Reimplement introspection errors and cache
jwhitlock e4e2a6f
Tune name, docstrings for mocks
jwhitlock ea44f04
Add tests for failed IntrospectionResponse init
jwhitlock 37148fd
Drop v=1 in cache data, YNGNI
jwhitlock f83eec1
Refactor IntrospectionError.raiseException
jwhitlock 59bb34d
Refactor __repr__, add tests
jwhitlock 1c64046
Test IntrospectionError.__eq__
jwhitlock 5724b07
Use setup_fxa_introspect for all introspect tests
jwhitlock f8ab152
Convert introspect_token tests to pytest
jwhitlock 0d6bd9a
Add pytest fixture for cache
jwhitlock 7c5aa49
IntrospectionError.save_to_cache tests
jwhitlock 8e51773
Add tests for load_introspection_result_from_cache
jwhitlock db4c21f
Convert introspect_token_or_raise tests
jwhitlock 316baa9
Ignore typing's assert_never()
jwhitlock 0fcd981
Convert FxaTokenAuthentication tests to pytest
jwhitlock a1bb7ee
Add variant where Relay user is optional
jwhitlock 8b6389b
Use auth class for terms_acccepted_user
jwhitlock 3779aa3
Pass the token to functions that use the cache
jwhitlock bf781a7
Add .as_cache_value()
jwhitlock afc6a79
Add type hints
jwhitlock 3029928
Use .as_cache_value in auth tests
jwhitlock 6869ed1
Use .as_cache_value in view tests
jwhitlock ddbe64a
Fix assert_called_once_with
jwhitlock db00dad
Fix assert_called_once_with, again
jwhitlock b003d18
Add links to platform docs
jwhitlock 07f8e0a
Use hash of token as cache key
jwhitlock ed24da7
Fix typo
jwhitlock 42249bf
Add token to IntrospectionReponse/Error
jwhitlock 4224055
Add HasValidFxaToken permission
jwhitlock 01c3ca6
Remove FxaTokenAuthenticationRelayUserOptional
jwhitlock b3f104d
Remove permission checks from auth
jwhitlock 3d4bb17
Add request_s timer to introspect results
jwhitlock 20ced7e
Capture introspection request time
jwhitlock 5c1cad9
Log introspection time
jwhitlock 949fba7
Log profile fetch time
jwhitlock b6c3c99
Update comments
jwhitlock b446ae5
Revert to nested if
jwhitlock 77082d9
Emit timing metric for introspect errors
jwhitlock 3983cdd
Emit timing metric for introspect success
jwhitlock e5b2e11
Update docstring
jwhitlock 66fdab9
Update docstring
jwhitlock 03e36f1
Rearrange _get_fxa_profile_from_bearer_token
jwhitlock 4fce545
Change _get_fxa_profile_from_bearer_tokeni return
jwhitlock f441c79
Emit timing metric for profile fetch
jwhitlock 1bd6146
Add IntrospectionResponse.is_expired
jwhitlock b191ebc
Check token expiration
jwhitlock a07e19a
Add env FXA_TOKEN_AUTH_VERSION
jwhitlock f6099b6
Add return code for TokenExpired
jwhitlock cfdbeba
Be less specific about message
jwhitlock fec4a1d
Fix spelling
jwhitlock da6f636
Include all params in repr, even if default
jwhitlock 79aecc8
Ensure shlex.quote only gets strings
jwhitlock 624f00f
Encode FxA data as base64
jwhitlock ef5bfe7
Encode FxA non-JSON data as base64
jwhitlock c6d496f
Encode FxA JSON non-dict data as base64
jwhitlock 12b9be5
Move FxA token grace period to settings
jwhitlock 3bad23d
If FxA omits or changes exp, log and use default
jwhitlock 671c4db
Attempt to continue on non-200 from introspect
jwhitlock 3b8ec41
Update TermsAcceptedUserViewTest for new errors
jwhitlock c52d32a
Update existing code (2024 version) from main
jwhitlock a566b7a
Rename to x_2025
jwhitlock ce6ae11
Refactor existing / new implementations, bump year
jwhitlock File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.