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

mozilla / fx-private-relay / 84353759-c057-4f20-b282-724c34504dc9

26 Nov 2025 04:22PM UTC coverage: 89.192% (+0.4%) from 88.772%
84353759-c057-4f20-b282-724c34504dc9

Pull #6049

circleci

jwhitlock
Update TermsAcceptedUserViewTest for new errors
Pull Request #6049: fix(relay): Create alternate bearer token auth for FxA (MPP-3505)

3016 of 4041 branches covered (74.63%)

Branch coverage included in aggregate %.

1334 of 1349 new or added lines in 9 files covered. (98.89%)

6 existing lines in 2 files now uncovered.

19067 of 20718 relevant lines covered (92.03%)

11.09 hits per line

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

96.78
/api/authentication.py
1
from __future__ import annotations
1✔
2

3
import logging
1✔
4
import shlex
1✔
5
from base64 import b64encode
1✔
6
from datetime import UTC, datetime, timedelta
1✔
7
from hashlib import sha256
1✔
8
from typing import Any, Literal, NoReturn, TypedDict, cast
1✔
9

10
from django.conf import settings
1✔
11
from django.contrib.auth.models import AnonymousUser, User
1✔
12
from django.core.cache import BaseCache, cache
1✔
13

14
import requests
1✔
15
import sentry_sdk
1✔
16
from allauth.socialaccount.models import SocialAccount
1✔
17
from codetiming import Timer
1✔
18
from markus.utils import generate_tag
1✔
19
from rest_framework.authentication import (
1✔
20
    BaseAuthentication,
21
    TokenAuthentication,
22
    get_authorization_header,
23
)
24
from rest_framework.exceptions import (
1✔
25
    APIException,
26
    AuthenticationFailed,
27
    NotFound,
28
    ParseError,
29
    PermissionDenied,
30
)
31
from rest_framework.request import Request
1✔
32

33
from emails.utils import histogram_if_enabled
1✔
34

35
logger = logging.getLogger("events")
1✔
36
INTROSPECT_TOKEN_URL = "{}/introspect".format(
1✔
37
    settings.SOCIALACCOUNT_PROVIDERS["fxa"]["OAUTH_ENDPOINT"]
38
)
39

40
# Specify the version strings in FXA_TOKEN_AUTH_VERSION
41
#
42
# The older version ("2024") works, but has a few issues.
43
# The cache key changes between Python instances, so little or no cache hits are used.
44
# Fetching a profile takes a few seconds, in which time another process can create a
45
# SocialAccount, leading to IntegrityError. Some of these are tracked in MPP-3505.
46
#
47
# The newer version ("2025") addresses these issues, works more like a standard DRF
48
# authentication class, expands the logged data, and tracks the time to call Accounts
49
# introspection and profile APIs. However, it is unproven, so we're using an
50
# environment variable to be able to try it in stage before production, and to
51
# revert with a config change only.
52
#
53
# The names are designed to be annoying so they will be removed. The old code has
54
# the suffix _2024 and the new code _2025 (when needed). When the new code is
55
# proven, the old code can be removed with minimal name changes.
56
#
57
# ruff thinks the strings "2024" and "2025" are passwords (check S105 / S106).
58
# These constants allow telling ruff to ignore them once.
59
FXA_TOKEN_AUTH_OLD_AND_PROVEN = "2024"  # noqa: S105
1✔
60
FXA_TOKEN_AUTH_NEW_AND_BUSTED = "2025"  # noqa: S105
1✔
61

62

63
class CachedFxaIntrospectResponse(TypedDict, total=False):
1✔
64
    """The data stored in the cache to avoid multiple introspection requests."""
65

66
    status_code: int
1✔
67
    data: FxaIntrospectData
1✔
68
    error: INTROSPECT_ERROR
1✔
69
    error_args: list[str]
1✔
70

71

72
class FxaIntrospectData(TypedDict, total=False):
1✔
73
    """Keys seen in the JSON returned from a Mozilla Accounts introspection request"""
74

75
    active: bool
1✔
76
    sub: str
1✔
77
    exp: int
1✔
78
    scope: str
1✔
79
    error: str
1✔
80

81

82
class FxaIntrospectCompleteData(TypedDict):
1✔
83
    """
84
    A valid Mozilla Accounts introspection response.
85

86
    There are more keys (scope, client_id, token_type, iat, jti) that are
87
    present but unused. See Firefox Ecosystem Platform docs:
88

89
    https://mozilla.github.io/ecosystem-platform/api#tag/OAuth-Server-API-Overview/operation/postIntrospect
90
    """
91

92
    active: bool
1✔
93
    sub: str
1✔
94
    exp: int
1✔
95

96

97
def get_cache_key_2024(token):
1✔
98
    """note: hash() returns different results in different Python processes."""
99
    return hash(token)
1✔
100

101

102
def get_cache_key(token: str) -> str:
1✔
103
    return f"introspect_result:v1:{sha256(token.encode()).hexdigest()}"
1✔
104

105

106
class IntrospectionResponse:
1✔
107
    def __init__(
1✔
108
        self,
109
        token: str,
110
        data: FxaIntrospectData,
111
        from_cache: bool = False,
112
        request_s: float | None = None,
113
    ):
114
        # Check if this should have been an IntrospectionError
115
        if "active" not in data or data["active"] is not True:
1✔
116
            raise ValueError("active should be true")
1✔
117
        if "sub" not in data or not isinstance(data["sub"], str) or not data["sub"]:
1✔
118
            raise ValueError("sub (FxA ID) should be set")
1✔
119
        if "exp" not in data or not isinstance(data["exp"], int):
1✔
120
            raise ValueError("exp (Expiration timestamp in milliseconds) should be int")
1✔
121
        if settings.RELAY_SCOPE not in data.get("scope", "").split():
1✔
122
            raise ValueError(f"scope should include {settings.RELAY_SCOPE!r}")
1✔
123

124
        self.token = token
1✔
125
        self.data: FxaIntrospectCompleteData = cast(FxaIntrospectCompleteData, data)
1✔
126
        self.from_cache = from_cache
1✔
127
        self.request_s = None if request_s is None else round(request_s, 3)
1✔
128

129
    def __repr__(self) -> str:
1✔
130
        return (
1✔
131
            f"{self.__class__.__name__}"
132
            f"(token={self.token!r},"
133
            f" data={self.data!r},"
134
            f" from_cache={self.from_cache!r},"
135
            f" request_s={self.request_s!r})"
136
        )
137

138
    def __eq__(self, other: Any) -> bool:
1✔
139
        if isinstance(other, IntrospectionResponse):
1✔
140
            return (
1✔
141
                (self.token == other.token)
142
                and (self.data == other.data)
143
                and (self.from_cache == other.from_cache)
144
                and (self.request_s == other.request_s)
145
            )
146
        return False
1✔
147

148
    def as_cache_value(self) -> CachedFxaIntrospectResponse:
1✔
149
        return {
1✔
150
            "data": cast(FxaIntrospectData, self.data),
151
        }
152

153
    def save_to_cache(self, cache: BaseCache, token: str, timeout: int) -> None:
1✔
154
        cache.set(get_cache_key(token), self.as_cache_value(), timeout)
1✔
155

156
    @property
1✔
157
    def time_to_expire(self) -> int:
1✔
158
        """
159
        Return the expiration time in seconds from now for an introspected token.
160

161
        If `exp` is omitted, a value for about 1 year ago is returned.
162
        """
163
        if "exp" not in self.data:
1!
NEW
164
            return -(365 * 24 * 60 * 60)
×
165
        # Note: FXA exp, other timestamps are milliseconds
166
        fxa_token_exp_time = int(self.data["exp"] / 1000)
1✔
167
        now_time = int(datetime.now(UTC).timestamp())
1✔
168
        return fxa_token_exp_time - now_time
1✔
169

170
    @property
1✔
171
    def cache_timeout(self) -> int:
1✔
172
        """
173
        Return the timeout in seconds from now for an introspected token.
174

175
        The minimum is 0, which signals to not cache.
176

177
        Typical expiration is 24 - 48 hours. The token could be revoked before
178
        the expiration time, so we may want an upper cache limit or to ensure
179
        the cache is skipped for some operations.
180
        """
181
        return max(0, self.time_to_expire)
1✔
182

183
    @property
1✔
184
    def is_expired(self) -> bool:
1✔
185
        return self.time_to_expire <= -(settings.FXA_TOKEN_EXPIRATION_GRACE_PERIOD)
1✔
186

187
    @property
1✔
188
    def fxa_id(self) -> str:
1✔
189
        return self.data["sub"]
1✔
190

191

192
INTROSPECT_ERROR = Literal[
1✔
193
    "Timeout",  # Introspection API took too long to respond
194
    "FailedRequest",  # Introspection API request failed
195
    "NotJson",  # Introspection API did not return JSON
196
    "NotJsonDict",  # Introspection API did not return a JSON dictionary
197
    "NotOK",  # Introspection API did not return a 200 or 401 response
198
    "NotAuthorized",  # Introspection API returned a 401 response
199
    "NotActive",  # The Accounts user is inactive
200
    "NoSubject",  # Introspection API did not return a "sub" field
201
    "MissingScope",  # The Accounts user does not have the relay scope
202
    "TokenExpired",  # The token is expired according to our clock
203
]
204

205

206
def as_b64(data: Any) -> str:
1✔
207
    """Return a potentially sensitive value as base64 encoded"""
208
    return "b64:" + b64encode(repr(data).encode()).decode()
1✔
209

210

211
class IntrospectionError:
1✔
212
    def __init__(
1✔
213
        self,
214
        token: str,
215
        error: INTROSPECT_ERROR,
216
        error_args: list[str] | None = None,
217
        status_code: int | None = None,
218
        data: FxaIntrospectData | None = None,
219
        from_cache: bool = False,
220
        request_s: float | None = None,
221
    ):
222
        self.token = token
1✔
223
        self.error = error
1✔
224
        self.error_args = error_args or []
1✔
225
        self.status_code = status_code
1✔
226
        self.data = data
1✔
227
        self.from_cache = from_cache
1✔
228
        self.request_s = None if request_s is None else round(request_s, 3)
1✔
229

230
    def __repr__(self) -> str:
1✔
231
        return (
1✔
232
            f"{self.__class__.__name__}"
233
            f"(token={self.token!r},"
234
            f" error={self.error!r},"
235
            f" error_args={self.error_args!r},"
236
            f" status_code={self.status_code!r},"
237
            f" data={self.data!r},"
238
            f" from_cache={self.from_cache!r},"
239
            f" request_s={self.request_s!r})"
240
        )
241

242
    def __eq__(self, other: Any) -> bool:
1✔
243
        if isinstance(other, IntrospectionError):
1✔
244
            return (
1✔
245
                (self.token == other.token)
246
                and (self.status_code == other.status_code)
247
                and (self.data == other.data)
248
                and (self.error == other.error)
249
                and (self.error_args == other.error_args)
250
                and (self.from_cache == other.from_cache)
251
                and (self.request_s == other.request_s)
252
            )
253
        return False
1✔
254

255
    _log_failure: set[INTROSPECT_ERROR] = {
1✔
256
        "Timeout",
257
        "FailedRequest",
258
        "NotJson",
259
        "NotJsonDict",
260
        "NotOK",
261
        "NoSubject",
262
        "TokenExpired",
263
    }
264

265
    _exception_code: dict[INTROSPECT_ERROR, Literal[401, 503]] = {
1✔
266
        "Timeout": 503,
267
        "FailedRequest": 503,
268
        "NotJson": 503,
269
        "NotJsonDict": 503,
270
        "NotOK": 503,
271
        "NotAuthorized": 401,
272
        "NotActive": 401,
273
        "NoSubject": 503,
274
        "MissingScope": 401,
275
        "TokenExpired": 401,
276
    }
277

278
    def raise_exception(self, method: str, path: str) -> NoReturn:
1✔
279
        if not self.from_cache and self.error in self._log_failure:
1✔
280
            logger.error(
1✔
281
                "accounts_introspection_failed",
282
                extra={
283
                    "error": self.error,
284
                    "error_args": [shlex.quote(str(arg)) for arg in self.error_args],
285
                    "status_code": self.status_code,
286
                    "data": as_b64(self.data),
287
                    "method": method,
288
                    "path": path,
289
                    "introspection_time_s": self.request_s,
290
                },
291
            )
292
        code = self._exception_code[self.error]
1✔
293
        if code == 401:
1✔
294
            raise IntrospectAuthenticationFailed(self)
1✔
295
        elif code == 503:
1!
296
            raise IntrospectUnavailable(self)
1✔
297

298
    def as_cache_value(self) -> CachedFxaIntrospectResponse:
1✔
299
        cached: CachedFxaIntrospectResponse = {"error": self.error}
1✔
300
        if self.status_code:
1✔
301
            cached["status_code"] = self.status_code
1✔
302
        if self.data:
1✔
303
            cached["data"] = self.data
1✔
304
        if self.error_args:
1✔
305
            cached["error_args"] = self.error_args
1✔
306
        return cached
1✔
307

308
    def save_to_cache(self, cache: BaseCache, token: str, timeout: int) -> None:
1✔
309
        cache.set(get_cache_key(token), self.as_cache_value(), timeout)
1✔
310

311

312
class IntrospectUnavailable(APIException):
1✔
313
    status_code = 503
1✔
314
    default_detail = "Introspection temporarily unavailable, try again later."
1✔
315
    default_code = "introspection_service_unavailable"
1✔
316

317
    def __init__(
1✔
318
        self, introspection_error: IntrospectionError, *args: Any, **kwargs: Any
319
    ) -> None:
320
        self.introspection_error = introspection_error
1✔
321
        super().__init__(*args, **kwargs)
1✔
322

323

324
class IntrospectAuthenticationFailed(AuthenticationFailed):
1✔
325
    def __init__(
1✔
326
        self, introspection_error: IntrospectionError, *args: Any, **kwargs: Any
327
    ) -> None:
328
        self.introspection_error = introspection_error
1✔
329
        super().__init__(*args, **kwargs)
1✔
330

331

332
def load_introspection_result_from_cache(
1✔
333
    cache: BaseCache, token: str
334
) -> IntrospectionResponse | IntrospectionError | None:
335
    cache_key = get_cache_key(token)
1✔
336
    cached = cache.get(cache_key)
1✔
337
    if cached is None or not isinstance(cached, dict):
1✔
338
        return None
1✔
339
    data_maybe = cached.get("data")
1✔
340
    if error := cached.get("error"):
1✔
341
        return IntrospectionError(
1✔
342
            token,
343
            error=error,
344
            status_code=cached.get("status_code"),
345
            data=data_maybe,
346
            error_args=cached.get("error_args"),
347
            from_cache=True,
348
        )
349
    response = IntrospectionResponse(token, data=data_maybe, from_cache=True)
1✔
350
    if response.is_expired:
1✔
351
        return IntrospectionError(
1✔
352
            token,
353
            "TokenExpired",
354
            error_args=[str(response.time_to_expire)],
355
            data=data_maybe,
356
            from_cache=True,
357
        )
358
    return response
1✔
359

360

361
def introspect_token_2024(token: str) -> dict[str, Any]:
1✔
362
    try:
1✔
363
        fxa_resp = requests.post(
1✔
364
            INTROSPECT_TOKEN_URL,
365
            json={"token": token},
366
            timeout=settings.FXA_REQUESTS_TIMEOUT_SECONDS,
367
        )
368
    except Exception as exc:
×
369
        logger.error(
×
370
            "Could not introspect token with FXA.",
371
            extra={"error_cls": type(exc), "error": shlex.quote(str(exc))},
372
        )
373
        raise AuthenticationFailed("Could not introspect token with FXA.")
×
374

375
    fxa_resp_data = {"status_code": fxa_resp.status_code, "json": {}}
1✔
376
    try:
1✔
377
        fxa_resp_data["json"] = fxa_resp.json()
1✔
378
    except requests.exceptions.JSONDecodeError:
1✔
379
        logger.error(
1✔
380
            "JSONDecodeError from FXA introspect response.",
381
            extra={"fxa_response": shlex.quote(fxa_resp.text)},
382
        )
383
        raise AuthenticationFailed("JSONDecodeError from FXA introspect response")
1✔
384
    return fxa_resp_data
1✔
385

386

387
def get_fxa_uid_from_oauth_token_2024(token: str, use_cache: bool = True) -> str:
1✔
388
    # set a default cache_timeout, but this will be overridden to match
389
    # the 'exp' time in the JWT returned by FxA
390
    cache_timeout = 60
1✔
391
    cache_key = get_cache_key_2024(token)
1✔
392

393
    if not use_cache:
1✔
394
        fxa_resp_data = introspect_token_2024(token)
1✔
395
    else:
396
        # set a default fxa_resp_data, so any error during introspection
397
        # will still cache for at least cache_timeout to prevent an outage
398
        # from causing useless run-away repetitive introspection requests
399
        fxa_resp_data = {"status_code": None, "json": {}}
1✔
400
        try:
1✔
401
            cached_fxa_resp_data = cache.get(cache_key)
1✔
402

403
            if cached_fxa_resp_data:
1✔
404
                fxa_resp_data = cached_fxa_resp_data
1✔
405
            else:
406
                # no cached data, get new
407
                fxa_resp_data = introspect_token_2024(token)
1✔
408
        except AuthenticationFailed:
1✔
409
            raise
1✔
410
        finally:
411
            # Store potential valid response, errors, inactive users, etc. from FxA
412
            # for at least 60 seconds. Valid access_token cache extended after checking.
413
            cache.set(cache_key, fxa_resp_data, cache_timeout)
1✔
414

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

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

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

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

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

442
    return fxa_uid
1✔
443

444

445
def introspect_token(token: str) -> IntrospectionResponse | IntrospectionError:
1✔
446
    """
447
    Validate an Accounts OAuth token with the introspect API.
448

449
    If it is a valid token for an Accounts user, returns IntrospectionResponse.
450
    If there are any issues, returns IntrospectionError.
451

452
    See Firefox Ecosystem Platform docs:
453
    https://mozilla.github.io/ecosystem-platform/api#tag/OAuth-Server-API-Overview/operation/postIntrospect
454
    """
455
    try:
1✔
456
        with Timer(logger=None) as request_timer:
1✔
457
            fxa_resp = requests.post(
1✔
458
                INTROSPECT_TOKEN_URL,
459
                json={"token": token},
460
                timeout=settings.FXA_REQUESTS_TIMEOUT_SECONDS,
461
            )
462
    except requests.Timeout:
1✔
463
        return IntrospectionError(token, "Timeout", request_s=request_timer.last)
1✔
464
    except Exception as exc:
1✔
465
        error_args = [exc.__class__.__name__]
1✔
466
        error_args.extend(exc.args)
1✔
467
        return IntrospectionError(
1✔
468
            token, "FailedRequest", error_args=error_args, request_s=request_timer.last
469
        )
470

471
    status_code = fxa_resp.status_code
1✔
472
    request_s = request_timer.last
1✔
473
    try:
1✔
474
        data = fxa_resp.json()
1✔
475
    except requests.exceptions.JSONDecodeError:
1✔
476
        return IntrospectionError(
1✔
477
            token,
478
            "NotJson",
479
            status_code=status_code,
480
            error_args=[as_b64(fxa_resp.text)],
481
            request_s=request_s,
482
        )
483
    if not isinstance(data, dict):
1✔
484
        return IntrospectionError(
1✔
485
            token,
486
            "NotJsonDict",
487
            status_code=status_code,
488
            error_args=[as_b64(data)],
489
            request_s=request_s,
490
        )
491

492
    if status_code == 401:
1✔
493
        return IntrospectionError(
1✔
494
            token,
495
            "NotAuthorized",
496
            error_args=[as_b64(data)],
497
            status_code=status_code,
498
            request_s=request_s,
499
        )
500
    with sentry_sdk.new_scope():
1✔
501
        sentry_sdk.set_context(
1✔
502
            "introspect_token", {"status_code": status_code, "data": data}
503
        )
504
        fxa_data = cast(FxaIntrospectData, data)
1✔
505

506
        if status_code != 200:
1!
507
            # Log but attempt to continue
NEW
508
            sentry_sdk.capture_message(
×
509
                f"FxA token introspect returned {status_code}, expected 200"
510
            )
511
            # Old version - log, raise 503
512
            # return IntrospectionError(
513
            #     token,
514
            #     "NotOK",
515
            #     status_code=status_code,
516
            #     data=fxa_data,
517
            #     request_s=request_s,
518
            # )
519

520
        if data.get("active", False) is not True:
1✔
521
            return IntrospectionError(
1✔
522
                token,
523
                "NotActive",
524
                status_code=status_code,
525
                data=fxa_data,
526
                request_s=request_s,
527
            )
528

529
        if not isinstance(sub := data.get("sub", None), str) or not sub:
1✔
530
            return IntrospectionError(
1✔
531
                token,
532
                "NoSubject",
533
                status_code=status_code,
534
                data=fxa_data,
535
                request_s=request_s,
536
            )
537

538
        if not isinstance(data.get("exp", None), int):
1✔
539
            sentry_sdk.capture_message("exp is not int")
1✔
540
            future = datetime.now() + timedelta(
1✔
541
                seconds=settings.FXA_TOKEN_EXPIRATION_GRACE_PERIOD
542
            )
543
            fxa_data["exp"] = int(future.timestamp()) * 1000
1✔
544

545
        scopes = data.get("scope", "").split()
1✔
546
        if settings.RELAY_SCOPE not in scopes:
1✔
547
            return IntrospectionError(
1✔
548
                token,
549
                "MissingScope",
550
                status_code=status_code,
551
                data=fxa_data,
552
                request_s=request_s,
553
            )
554

555
        response = IntrospectionResponse(token, data=fxa_data, request_s=request_s)
1✔
556
        if response.is_expired:
1!
NEW
557
            return IntrospectionError(
×
558
                token,
559
                "TokenExpired",
560
                error_args=[str(response.time_to_expire)],
561
                data=fxa_data,
562
                request_s=request_s,
563
            )
564
    return response
1✔
565

566

567
def introspect_and_cache_token(
1✔
568
    token: str, read_from_cache: bool = True
569
) -> IntrospectionResponse | IntrospectionError:
570
    """
571
    Introspect a Mozilla account OAuth token, to get data like the FxA UID.
572

573
    If anything goes wrong, raise an exception.
574
    """
575
    default_cache_timeout = settings.FXA_TOKEN_EXPIRATION_GRACE_PERIOD
1✔
576

577
    # Get a cached or live introspection response
578
    fxa_resp: IntrospectionResponse | IntrospectionError | None = None
1✔
579
    if read_from_cache:
1✔
580
        fxa_resp = load_introspection_result_from_cache(cache, token)
1✔
581
    if fxa_resp is None:
1✔
582
        fxa_resp = introspect_token(token)
1✔
583

584
    # If the response is an error, raise an exception
585
    if isinstance(fxa_resp, IntrospectionError):
1✔
586
        if not fxa_resp.from_cache:
1✔
587
            fxa_resp.save_to_cache(cache, token, default_cache_timeout)
1✔
588
        return fxa_resp
1✔
589

590
    # Save a live introspection response to the cache
591
    if not fxa_resp.from_cache:
1✔
592
        cache_timeout = max(default_cache_timeout, fxa_resp.cache_timeout)
1✔
593
        fxa_resp.save_to_cache(cache, token, cache_timeout)
1✔
594
    return fxa_resp
1✔
595

596

597
class FxaTokenAuthentication(BaseAuthentication):
1✔
598
    """Pick 2024 or 2025 version based on settings"""
599

600
    _impl: FxaTokenAuthentication2024 | FxaTokenAuthentication2025
1✔
601

602
    def __init__(self) -> None:
1✔
603
        if settings.FXA_TOKEN_AUTH_VERSION == FXA_TOKEN_AUTH_NEW_AND_BUSTED:
1✔
604
            self._impl = FxaTokenAuthentication2025()
1✔
605
        else:
606
            self._impl = FxaTokenAuthentication2024()
1✔
607

608
    def authenticate_header(self, request: Request) -> Any | str | None:
1✔
609
        return self._impl.authenticate_header(request)
1✔
610

611
    def authenticate(
1✔
612
        self, request: Request
613
    ) -> None | tuple[User | AnonymousUser, IntrospectionResponse]:
614
        return self._impl.authenticate(request)
1✔
615

616

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

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

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

635
        use_cache = True
1✔
636
        method = request.method
1✔
637
        if method in ["POST", "DELETE", "PUT"]:
1✔
638
            use_cache = False
1✔
639
            if method == "POST" and request.path == "/api/v1/relayaddresses/":
1✔
640
                use_cache = True
1✔
641
        fxa_uid = get_fxa_uid_from_oauth_token_2024(token, use_cache)
1✔
642
        try:
1✔
643
            # MPP-3021: select_related user object to save DB query
644
            sa = SocialAccount.objects.filter(
1✔
645
                uid=fxa_uid, provider="fxa"
646
            ).select_related("user")[0]
647
        except IndexError:
1✔
648
            raise PermissionDenied(
1✔
649
                "Authenticated user does not have a Relay account."
650
                " Have they accepted the terms?"
651
            )
652
        user = sa.user
1✔
653

654
        if not user.is_active:
1✔
655
            raise PermissionDenied(
1✔
656
                "Authenticated user does not have an active Relay account."
657
                " Have they been deactivated?"
658
            )
659

660
        if user:
1!
661
            return (user, token)
1✔
662
        else:
UNCOV
663
            raise NotFound()
×
664

665

666
class FxaTokenAuthentication2025(TokenAuthentication):
1✔
667
    """
668
    Implement authentication with a Mozilla Account bearer token.
669

670
    This is passed by Firefox for the Accounts user. Unlike DRF's
671
    TokenAuthentication, this is not generated by Relay. Instead, it
672
    needs to be validated by Mozilla Accounts to get the FxA ID.
673
    """
674

675
    keyword = "Bearer"
1✔
676

677
    def authenticate(
1✔
678
        self, request: Request
679
    ) -> None | tuple[User | AnonymousUser, IntrospectionResponse]:
680
        """
681
        Try to authenticate with a Accounts bearer token.
682

683
        If successful, it returns a tuple (user, token), which can be accessed at
684
        request.user and request.auth. If there is a not a matching Relay user, then
685
        the user is an AnonymousUser. Also, request.successful_authenticator will be
686
        an instance of this class.
687

688
        If it fails, it raises an APIException with a status code:
689
        * 503 Service Unavailable - The introspect API request failed, or had bad data
690
        * 401 Authentication Failed - The introspect API says the account is inactive,
691
          or the token is invalid.
692

693
        If the authentication header is not an Accounts bearer token, it returns None
694
        to skip to the next authentication method.
695
        """
696
        self.method = request.method or "unknown"
1✔
697
        self.path = request.path
1✔
698
        # Validate the token header, call authentication_credentials
699
        return super().authenticate(request)
1✔
700

701
    def authenticate_credentials(
1✔
702
        self, key: str
703
    ) -> tuple[User | AnonymousUser, IntrospectionResponse]:
704
        """
705
        Authenticate the bearer token.
706

707
        This is called by DRF authentication framework's authenticate.
708
        """
709
        read_from_cache = True
1✔
710
        if self.method in {"POST", "DELETE", "PUT"}:
1✔
711
            # Require token re-inspection for methods that change content...
712
            read_from_cache = False
1✔
713
            if self.method == "POST" and self.path == "/api/v1/relayaddresses/":
1✔
714
                # ... except for creating a new random address (MPP-3156)
715
                read_from_cache = True
1✔
716

717
        introspection_result = introspect_and_cache_token(key, read_from_cache)
1✔
718
        if introspection_result.request_s is not None:
1✔
719
            if isinstance(introspection_result, IntrospectionResponse):
1✔
720
                result = "OK"
1✔
721
            else:
722
                result = introspection_result.error
1✔
723
            histogram_if_enabled(
1✔
724
                name="accounts_introspection_ms",
725
                value=int(introspection_result.request_s * 1000),
726
                tags=[
727
                    generate_tag("result", result),
728
                    generate_tag("method", self.method),
729
                    generate_tag("path", self.path),
730
                ],
731
            )
732

733
        if isinstance(introspection_result, IntrospectionError):
1✔
734
            introspection_result.raise_exception(self.method, self.path)
1✔
735

736
        fxa_id = introspection_result.fxa_id
1✔
737
        try:
1✔
738
            sa = SocialAccount.objects.select_related("user").get(
1✔
739
                uid=fxa_id, provider="fxa"
740
            )
741
        except SocialAccount.DoesNotExist:
1✔
742
            return (AnonymousUser(), introspection_result)
1✔
743
        return (sa.user, introspection_result)
1✔
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