• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mozilla / fx-private-relay / faa6612c-1b77-48f4-8815-6f5f88d8e978

22 Oct 2025 07:41PM UTC coverage: 88.838% (-0.005%) from 88.843%
faa6612c-1b77-48f4-8815-6f5f88d8e978

push

circleci

web-flow
Merge pull request #5988 from mozilla/MPP-4460/unknown-fxa-event

task: Add more context for FxA event issues (MPP-4460)

2913 of 3927 branches covered (74.18%)

Branch coverage included in aggregate %.

31 of 35 new or added lines in 2 files covered. (88.57%)

1 existing line in 1 file now uncovered.

18098 of 19724 relevant lines covered (91.76%)

11.24 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

74.38
/privaterelay/views.py
1
from __future__ import annotations
1✔
2

3
import json
1✔
4
import logging
1✔
5
from collections.abc import Iterable
1✔
6
from datetime import UTC, datetime
1✔
7
from functools import cache
1✔
8
from hashlib import sha256
1✔
9
from typing import Any, TypedDict
1✔
10

11
from django.apps import apps
1✔
12
from django.conf import settings
1✔
13
from django.db import IntegrityError, transaction
1✔
14
from django.http import HttpRequest, HttpResponse, JsonResponse
1✔
15
from django.shortcuts import redirect
1✔
16
from django.urls import reverse
1✔
17
from django.views.decorators.csrf import csrf_exempt
1✔
18
from django.views.decorators.http import require_http_methods
1✔
19

20
import jwt
1✔
21
import sentry_sdk
1✔
22
from allauth.socialaccount.models import SocialAccount, SocialApp
1✔
23
from allauth.socialaccount.providers.fxa.views import FirefoxAccountsOAuth2Adapter
1✔
24
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
1✔
25
from markus.utils import generate_tag
1✔
26
from oauthlib.oauth2.rfc6749.errors import CustomOAuth2Error
1✔
27
from requests.exceptions import JSONDecodeError
1✔
28
from rest_framework.decorators import api_view, schema
1✔
29

30
from emails.models import DomainAddress, RelayAddress
1✔
31
from emails.utils import incr_if_enabled
1✔
32

33
from .apps import PrivateRelayConfig
1✔
34
from .exceptions import CannotMakeSubdomainException
1✔
35
from .fxa_utils import NoSocialToken, _get_oauth2_session
1✔
36
from .validators import valid_available_subdomain
1✔
37

38
FXA_SCHEMA_BASE = "https://schemas.accounts.firefox.com/event"
1✔
39
FXA_PROFILE_CHANGE_EVENT = FXA_SCHEMA_BASE + "/profile-change"
1✔
40
FXA_SUBSCRIPTION_CHANGE_EVENT = FXA_SCHEMA_BASE + "/subscription-state-change"
1✔
41
FXA_DELETE_EVENT = FXA_SCHEMA_BASE + "/delete-user"
1✔
42
FXA_PWD_CHANGE_EVENT = FXA_SCHEMA_BASE + "/password-change"
1✔
43
PROFILE_EVENTS = [FXA_PROFILE_CHANGE_EVENT, FXA_SUBSCRIPTION_CHANGE_EVENT]
1✔
44
IGNORED_EVENTS = [FXA_PWD_CHANGE_EVENT]
1✔
45

46
logger = logging.getLogger("events")
1✔
47
info_logger = logging.getLogger("eventsinfo")
1✔
48

49

50
@cache
1✔
51
def _get_fxa(request):
1✔
52
    return request.user.socialaccount_set.filter(provider="fxa").first()
×
53

54

55
@api_view()
1✔
56
@schema(None)
1✔
57
@require_http_methods(["GET"])
1✔
58
def profile_refresh(request):
1✔
59
    if not request.user or request.user.is_anonymous:
×
60
        return redirect(reverse("fxa_login"))
×
61
    profile = request.user.profile
×
62

63
    fxa = _get_fxa(request)
×
64
    update_fxa(fxa)
×
65
    if "clicked-purchase" in request.COOKIES and profile.has_premium:
×
66
        event = "user_purchased_premium"
×
67
        incr_if_enabled(event, 1)
×
68

69
    return JsonResponse({})
×
70

71

72
@api_view(["POST", "GET"])
1✔
73
@schema(None)
1✔
74
@require_http_methods(["POST", "GET"])
1✔
75
def profile_subdomain(request):
1✔
76
    if not request.user or request.user.is_anonymous:
×
77
        return redirect(reverse("fxa_login"))
×
78
    profile = request.user.profile
×
79
    if not profile.has_premium:
×
80
        raise CannotMakeSubdomainException("error-premium-check-subdomain")
×
81
    try:
×
82
        if request.method == "GET":
×
83
            subdomain = request.GET.get("subdomain", None)
×
84
            valid_available_subdomain(subdomain)
×
85
            return JsonResponse({"available": True})
×
86
        else:
87
            subdomain = request.POST.get("subdomain", None)
×
88
            profile.add_subdomain(subdomain)
×
89
            return JsonResponse(
×
90
                {"status": "Accepted", "message": "success-subdomain-registered"},
91
                status=202,
92
            )
93
    except CannotMakeSubdomainException as e:
×
94
        return JsonResponse({"message": e.message, "subdomain": subdomain}, status=400)
×
95

96

97
@csrf_exempt
1✔
98
@require_http_methods(["POST"])
1✔
99
def metrics_event(request: HttpRequest) -> JsonResponse:
1✔
100
    """
101
    Handle metrics events from the Relay extension.
102

103
    This used to forward data to Google Analytics, but was not updated for GA4.
104

105
    Now it logs the information and updates statsd counters.
106
    """
107
    try:
1✔
108
        request_data = json.loads(request.body)
1✔
109
    except json.JSONDecodeError:
1✔
110
        return JsonResponse({"msg": "Could not decode JSON"}, status=415)
1✔
111
    if "ga_uuid" not in request_data:
1✔
112
        return JsonResponse({"msg": "No GA uuid found"}, status=404)
1✔
113
    event_data = {
1✔
114
        "ga_uuid_hash": sha256(request_data["ga_uuid"].encode()).hexdigest()[:16],
115
        "category": request_data.get("category", None),
116
        "action": request_data.get("action", None),
117
        "label": request_data.get("label", None),
118
        "value": request_data.get("value", None),
119
        "browser": request_data.get("browser", None),  # dimension5 in GA
120
        "source": request_data.get("dimension7", "website"),
121
    }
122
    info_logger.info("metrics_event", extra=event_data)
1✔
123
    tags = [
1✔
124
        generate_tag(key, val)
125
        for key, val in event_data.items()
126
        if val is not None and key != "ga_uuid_hash"
127
    ]
128
    incr_if_enabled("metrics_event", tags=tags)
1✔
129
    return JsonResponse({"msg": "OK"}, status=200)
1✔
130

131

132
@csrf_exempt
1✔
133
def fxa_rp_events(request: HttpRequest) -> HttpResponse:
1✔
134
    """MPP-4460: Track more data for FxA relying party exceptions"""
135
    if (auth := request.headers.get("Authorization")) is None or not auth.startswith(
1✔
136
        "Bearer "
137
    ):
138
        return HttpResponse(
1✔
139
            "401 Unauthorized", status=401, headers={"WWW-Authenticate": "Bearer"}
140
        )
141
    req_jwt = _parse_jwt_from_request(request)
1✔
142
    authentic_jwt = _authenticate_fxa_jwt(req_jwt)
1✔
143
    with sentry_sdk.new_scope() as scope:
1✔
144
        sentry_context = {"body": request.body, "jwt": authentic_jwt}
1✔
145
        scope.set_context("fxa_rp_event", sentry_context)
1✔
146
        return fxa_rp_events_process_event(authentic_jwt, sentry_context)
1✔
147

148

149
def fxa_rp_events_process_event(
1✔
150
    authentic_jwt: FxAEvent, sentry_context: dict[str, Any]
151
) -> HttpResponse:
152
    """Augment previous version of fxa_rp_events with additional sentry data"""
153

154
    event_keys = _get_event_keys_from_jwt(authentic_jwt)
1✔
155
    try:
1✔
156
        social_account = _get_account_from_jwt(authentic_jwt)
1✔
157
    except SocialAccount.DoesNotExist:
×
158
        # Don't error, or FXA will retry
159
        return HttpResponse("202 Accepted", status=202)
×
160

161
    for event_key in event_keys:
1✔
162
        sentry_context["event_key"] = event_key
1✔
163
        if event_key in PROFILE_EVENTS:
1✔
164
            if settings.DEBUG:
1!
165
                info_logger.info(
×
166
                    "fxa_profile_update",
167
                    extra={
168
                        "jwt": authentic_jwt,
169
                        "event_key": event_key,
170
                    },
171
                )
172
            update_fxa(social_account, authentic_jwt, event_key)
1✔
173
        elif event_key == FXA_DELETE_EVENT:
1✔
174
            _handle_fxa_delete(authentic_jwt, social_account, event_key)
1✔
175
        elif event_key not in IGNORED_EVENTS:
1!
NEW
176
            sentry_sdk.capture_message(f"fxa_rp_events: Unknown event key {event_key}")
×
177
    return HttpResponse("200 OK", status=200)
1✔
178

179

180
def _parse_jwt_from_request(request: HttpRequest) -> str:
1✔
181
    request_auth = request.headers["Authorization"]
1✔
182
    return request_auth.split("Bearer ")[1]
1✔
183

184

185
def fxa_verifying_keys(reload: bool = False) -> list[dict[str, Any]]:
1✔
186
    """Get list of FxA verifying (public) keys."""
187
    private_relay_config = apps.get_app_config("privaterelay")
1✔
188
    if not isinstance(private_relay_config, PrivateRelayConfig):
1!
189
        raise TypeError("private_relay_config must be PrivateRelayConfig")
×
190
    if reload:
1✔
191
        private_relay_config.ready()
1✔
192
    return private_relay_config.fxa_verifying_keys
1✔
193

194

195
def fxa_social_app(reload: bool = False) -> SocialApp:
1✔
196
    """Get FxA SocialApp from app config or DB."""
197
    private_relay_config = apps.get_app_config("privaterelay")
1✔
198
    if not isinstance(private_relay_config, PrivateRelayConfig):
1!
199
        raise TypeError("private_relay_config must be PrivateRelayConfig")
×
200
    if reload:
1!
201
        private_relay_config.ready()
×
202
    return private_relay_config.fxa_social_app
1✔
203

204

205
class FxAEvent(TypedDict):
1✔
206
    """
207
    FxA Security Event Token (SET) payload, sent to relying parties.
208

209
    See:
210
    https://github.com/mozilla/fxa/tree/main/packages/fxa-event-broker
211
    https://www.rfc-editor.org/rfc/rfc8417 (Security Event Token)
212
    """
213

214
    iss: str  # Issuer, https://accounts.firefox.com/
1✔
215
    sub: str  # Subject, FxA user ID
1✔
216
    aud: str  # Audience, Relay's client ID
1✔
217
    iat: int  # Creation time, timestamp
1✔
218
    jti: str  # JWT ID, unique for this SET
1✔
219
    events: dict[str, dict[str, Any]]  # Event data
1✔
220

221

222
def _authenticate_fxa_jwt(req_jwt: str) -> FxAEvent:
1✔
223
    authentic_jwt = _verify_jwt_with_fxa_key(req_jwt, fxa_verifying_keys())
1✔
224

225
    if not authentic_jwt:
1!
226
        # FXA key may be old? re-fetch FXA keys and try again
227
        authentic_jwt = _verify_jwt_with_fxa_key(
×
228
            req_jwt, fxa_verifying_keys(reload=True)
229
        )
230
        if not authentic_jwt:
×
231
            raise Exception("Could not authenticate JWT with FXA key.")
×
232

233
    return authentic_jwt
1✔
234

235

236
def _verify_jwt_with_fxa_key(
1✔
237
    req_jwt: str, verifying_keys: list[dict[str, Any]]
238
) -> FxAEvent | None:
239
    if not verifying_keys:
1!
240
        raise Exception("FXA verifying keys are not available.")
×
241
    social_app = fxa_social_app()
1✔
242
    if not social_app:
1!
243
        raise Exception("FXA SocialApp is not available.")
×
244
    if not isinstance(social_app, SocialApp):
1!
245
        raise TypeError("social_app must be SocialApp")
×
246
    for verifying_key in verifying_keys:
1!
247
        if verifying_key["alg"] == "RS256":
1!
248
            public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(verifying_key))
1✔
249
            if not isinstance(public_key, RSAPublicKey):
1!
250
                raise TypeError("public_key must be RSAPublicKey")
×
251
            try:
1✔
252
                security_event = jwt.decode(
1✔
253
                    req_jwt,
254
                    public_key,
255
                    audience=social_app.client_id,
256
                    algorithms=["RS256"],
257
                    leeway=5,  # allow iat to be slightly in future, for clock skew
258
                )
259
            except jwt.ImmatureSignatureError:
1✔
260
                # Issue 2738: Log age of iat, if present
261
                claims = jwt.decode(
1✔
262
                    req_jwt,
263
                    public_key,
264
                    algorithms=["RS256"],
265
                    options={"verify_signature": False},
266
                )
267
                iat = claims.get("iat")
1✔
268
                iat_age = None
1✔
269
                if iat:
1!
270
                    iat_age = round(datetime.now(tz=UTC).timestamp() - iat, 3)
1✔
271
                info_logger.warning(
1✔
272
                    "fxa_rp_event.future_iat", extra={"iat": iat, "iat_age_s": iat_age}
273
                )
274
                raise
1✔
275
            return FxAEvent(
1✔
276
                iss=security_event["iss"],
277
                sub=security_event["sub"],
278
                aud=security_event["aud"],
279
                iat=security_event["iat"],
280
                jti=security_event["jti"],
281
                events=security_event["events"],
282
            )
283
    return None
×
284

285

286
def _get_account_from_jwt(authentic_jwt: FxAEvent) -> SocialAccount:
1✔
287
    social_account_uid = authentic_jwt["sub"]
1✔
288
    return SocialAccount.objects.get(uid=social_account_uid, provider="fxa")
1✔
289

290

291
def _get_event_keys_from_jwt(authentic_jwt: FxAEvent) -> Iterable[str]:
1✔
292
    return authentic_jwt["events"].keys()
1✔
293

294

295
def update_fxa(
1✔
296
    social_account: SocialAccount,
297
    authentic_jwt: FxAEvent | None = None,
298
    event_key: str | None = None,
299
) -> HttpResponse:
300
    """MPP-4460: Track more data for FxA profile update exceptions"""
301

302
    with sentry_sdk.new_scope() as scope:
1✔
303
        sentry_context = {
1✔
304
            "social_account_uid": social_account.uid,
305
            "authentic_jwt": authentic_jwt,
306
            "event_key": event_key,
307
        }
308
        scope.set_context("update_fxa", sentry_context)
1✔
309
        return update_fxa_inner(
1✔
310
            social_account, sentry_context, authentic_jwt, event_key
311
        )
312

313

314
def update_fxa_inner(
1✔
315
    social_account: SocialAccount,
316
    sentry_context: dict[str, Any],
317
    authentic_jwt: FxAEvent | None = None,
318
    event_key: str | None = None,
319
) -> HttpResponse:
320
    """Augment previous version of update_fxa with additional sentry data"""
321

322
    try:
1✔
323
        client = _get_oauth2_session(social_account)
1✔
324
    except NoSocialToken as e:
×
325
        sentry_sdk.capture_exception(e)
×
326
        return HttpResponse("202 Accepted", status=202)
×
327

328
    # TODO: more graceful handling of profile fetch failures
329
    try:
1✔
330
        resp = client.get(FirefoxAccountsOAuth2Adapter.profile_url)
1✔
331
    except CustomOAuth2Error as e:
×
332
        sentry_sdk.capture_exception(e)
×
333
        return HttpResponse("202 Accepted", status=202)
×
334

335
    sentry_context["profile_status_code"] = resp.status_code
1✔
336
    try:
1✔
337
        extra_data = resp.json()
1✔
NEW
338
    except JSONDecodeError:
×
NEW
339
        sentry_context["profile_resp_body"] = resp.body
×
NEW
340
        raise
×
341
    sentry_context["profile_resp"] = extra_data
1✔
342

343
    try:
1✔
344
        new_email = extra_data["email"]
1✔
345
    except KeyError as e:
×
346
        sentry_sdk.capture_exception(e)
×
347
        return HttpResponse("202 Accepted", status=202)
×
348

349
    if authentic_jwt and event_key:
1!
350
        info_logger.info(
1✔
351
            "fxa_rp_event",
352
            extra={
353
                "fxa_uid": authentic_jwt["sub"],
354
                "event_key": event_key,
355
                "real_address": sha256(new_email.encode("utf-8")).hexdigest(),
356
            },
357
        )
358

359
    return _update_all_data(social_account, extra_data, new_email)
1✔
360

361

362
def _update_all_data(
1✔
363
    social_account: SocialAccount, extra_data: dict[str, Any], new_email: str
364
) -> HttpResponse:
365
    try:
1✔
366
        profile = social_account.user.profile
1✔
367
        had_premium = profile.has_premium
1✔
368
        had_phone = profile.has_phone
1✔
369
        with transaction.atomic():
1✔
370
            social_account.extra_data = extra_data
1✔
371
            social_account.save()
1✔
372
            profile = social_account.user.profile
1✔
373
            now_has_premium = profile.has_premium
1✔
374
            newly_premium = not had_premium and now_has_premium
1✔
375
            no_longer_premium = had_premium and not now_has_premium
1✔
376
            if newly_premium:
1✔
377
                incr_if_enabled("user_purchased_premium", 1)
1✔
378
                profile.date_subscribed = datetime.now(UTC)
1✔
379
                profile.save()
1✔
380
            if no_longer_premium:
1!
381
                incr_if_enabled("user_has_downgraded", 1)
×
382
            now_has_phone = profile.has_phone
1✔
383
            newly_phone = not had_phone and now_has_phone
1✔
384
            no_longer_phone = had_phone and not now_has_phone
1✔
385
            if newly_phone:
1✔
386
                incr_if_enabled("user_purchased_phone", 1)
1✔
387
                profile.date_subscribed_phone = datetime.now(UTC)
1✔
388
                profile.date_phone_subscription_reset = datetime.now(UTC)
1✔
389
                profile.save()
1✔
390
            if no_longer_phone:
1!
391
                incr_if_enabled("user_has_dropped_phone", 1)
×
392
            social_account.user.email = new_email
1✔
393
            social_account.user.save()
1✔
394
            email_address_record = social_account.user.emailaddress_set.first()
1✔
395
            if email_address_record:
1✔
396
                email_address_record.email = new_email
1✔
397
                email_address_record.save()
1✔
398
            else:
399
                social_account.user.emailaddress_set.create(email=new_email)
1✔
400
            return HttpResponse("202 Accepted", status=202)
1✔
401
    except IntegrityError as e:
1✔
402
        sentry_sdk.capture_exception(e)
1✔
403
        return HttpResponse("Conflict", status=409)
1✔
404

405

406
def _handle_fxa_delete(
1✔
407
    authentic_jwt: FxAEvent, social_account: SocialAccount, event_key: str
408
) -> None:
409
    # Using for loops here because QuerySet.delete() does a bulk delete which does
410
    # not call the model delete() methods that create DeletedAddress records
411
    for relay_address in RelayAddress.objects.filter(user=social_account.user):
1✔
412
        relay_address.delete()
1✔
413
    for domain_address in DomainAddress.objects.filter(user=social_account.user):
1✔
414
        domain_address.delete()
1✔
415

416
    social_account.user.delete()
1✔
417
    info_logger.info(
1✔
418
        "fxa_rp_event",
419
        extra={
420
            "fxa_uid": authentic_jwt["sub"],
421
            "event_key": event_key,
422
        },
423
    )
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc