File size: 29,458 Bytes
95e8d10
abb2759
95e8d10
 
 
904d4e5
95e8d10
 
ba020b8
3959209
95e8d10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6176a5
 
95e8d10
 
 
 
 
 
abb2759
 
 
 
 
 
 
95e8d10
b6176a5
95e8d10
abb2759
95e8d10
 
 
09438a8
95e8d10
 
 
abb2759
95e8d10
 
 
 
 
abb2759
 
c1940a6
95e8d10
c29318c
95e8d10
 
 
 
 
 
 
 
 
 
 
 
91deb88
95e8d10
 
 
 
 
 
 
 
 
abb2759
 
95e8d10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
904d4e5
 
 
 
b6176a5
904d4e5
b6176a5
904d4e5
 
 
 
 
 
 
 
 
 
 
cc579d4
 
904d4e5
 
 
 
 
 
 
 
b6176a5
904d4e5
 
 
 
 
 
 
 
60b71f5
 
 
 
 
904d4e5
60b71f5
 
 
904d4e5
 
 
 
60b71f5
 
 
 
 
 
904d4e5
 
 
 
b6176a5
904d4e5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6176a5
904d4e5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6176a5
904d4e5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6176a5
904d4e5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6176a5
904d4e5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6176a5
904d4e5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95e8d10
 
 
b6176a5
95e8d10
 
 
 
 
 
 
 
 
b6176a5
95e8d10
 
 
 
 
 
 
 
 
 
 
 
 
b6176a5
95e8d10
 
 
 
 
 
b6176a5
95e8d10
 
 
 
 
 
b6176a5
95e8d10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6176a5
95e8d10
 
 
 
 
 
 
 
 
 
 
 
 
b6176a5
95e8d10
 
 
 
 
 
 
 
 
 
 
 
b6176a5
95e8d10
 
 
 
 
 
 
 
 
 
 
 
 
b6176a5
95e8d10
 
 
 
 
 
 
 
 
 
 
 
 
b6176a5
95e8d10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6176a5
95e8d10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
956706c
 
 
ca53851
b6176a5
ca53851
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95e8d10
b6176a5
95e8d10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6176a5
95e8d10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6176a5
95e8d10
 
 
 
 
 
 
 
 
 
 
 
 
 
5c6cd30
b6176a5
5c6cd30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6176a5
5c6cd30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6176a5
5c6cd30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6176a5
95e8d10
 
 
 
 
 
 
 
 
 
 
 
 
 
b6176a5
95e8d10
 
 
 
 
 
 
 
 
 
 
 
 
 
b6176a5
95e8d10
 
 
 
 
 
 
 
 
 
 
 
 
 
b163829
 
 
b6176a5
b163829
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6176a5
b163829
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6176a5
b163829
 
 
 
 
 
 
 
 
 
 
 
 
 
95e8d10
 
 
 
b6176a5
95e8d10
b6176a5
95e8d10
 
904d4e5
95e8d10
abb2759
904d4e5
abb2759
95e8d10
 
 
abb2759
 
 
 
904d4e5
abb2759
 
904d4e5
abb2759
 
 
 
 
 
 
 
 
 
904d4e5
abb2759
 
904d4e5
abb2759
 
 
 
 
 
904d4e5
abb2759
 
904d4e5
abb2759
904d4e5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c1940a6
904d4e5
7b03b73
904d4e5
 
 
b6176a5
 
 
 
904d4e5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c1940a6
904d4e5
7b03b73
904d4e5
 
 
3959209
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
from collections.abc import Generator
from typing import Any

import httpx
import pytest
from pytest_httpx import HTTPXMock

from fastmcp import Client, FastMCP
from fastmcp.client.auth.bearer import BearerAuth
from fastmcp.server.auth.providers.jwt import JWKData, JWKSData, JWTVerifier, RSAKeyPair
from fastmcp.utilities.tests import run_server_in_process


@pytest.fixture(scope="module")
def rsa_key_pair() -> RSAKeyPair:
    return RSAKeyPair.generate()


@pytest.fixture(scope="module")
def bearer_token(rsa_key_pair: RSAKeyPair) -> str:
    return rsa_key_pair.create_token(
        subject="test-user",
        issuer="https://test.example.com",
        audience="https://api.example.com",
    )


@pytest.fixture
def bearer_provider(rsa_key_pair: RSAKeyPair) -> JWTVerifier:
    return JWTVerifier(
        public_key=rsa_key_pair.public_key,
        issuer="https://test.example.com",
        audience="https://api.example.com",
    )


def run_mcp_server(
    public_key: str,
    host: str,
    port: int,
    auth_kwargs: dict[str, Any] | None = None,
    run_kwargs: dict[str, Any] | None = None,
) -> None:
    mcp = FastMCP(
        auth=JWTVerifier(
            public_key=public_key,
            **auth_kwargs or {},
        )
    )

    @mcp.tool
    def add(a: int, b: int) -> int:
        return a + b

    mcp.run(host=host, port=port, **run_kwargs or {})


@pytest.fixture(scope="module")
def mcp_server_url(rsa_key_pair: RSAKeyPair) -> Generator[str]:
    with run_server_in_process(
        run_mcp_server,
        public_key=rsa_key_pair.public_key,
        run_kwargs=dict(transport="http"),
    ) as url:
        yield f"{url}/mcp"


class TestRSAKeyPair:
    def test_generate_key_pair(self):
        """Test RSA key pair generation."""
        key_pair = RSAKeyPair.generate()

        assert key_pair.private_key is not None
        assert key_pair.public_key is not None

        # Check that keys are in PEM format
        private_pem = key_pair.private_key.get_secret_value()
        public_pem = key_pair.public_key

        assert "-----BEGIN PRIVATE KEY-----" in private_pem
        assert "-----END PRIVATE KEY-----" in private_pem
        assert "-----BEGIN PUBLIC KEY-----" in public_pem
        assert "-----END PUBLIC KEY-----" in public_pem

    def test_create_basic_token(self, rsa_key_pair: RSAKeyPair):
        """Test basic token creation."""
        token = rsa_key_pair.create_token(
            subject="test-user",
            issuer="https://test.example.com",
        )

        assert isinstance(token, str)
        assert len(token.split(".")) == 3  # JWT has 3 parts

    def test_create_token_with_scopes(self, rsa_key_pair: RSAKeyPair):
        """Test token creation with scopes."""
        token = rsa_key_pair.create_token(
            subject="test-user",
            issuer="https://test.example.com",
            scopes=["read", "write"],
        )

        assert isinstance(token, str)
        # We'll validate the scopes in the BearerToken tests


class TestBearerTokenJWKS:
    """Tests for JWKS URI functionality."""

    @pytest.fixture
    def jwks_provider(self, rsa_key_pair: RSAKeyPair) -> JWTVerifier:
        """Provider configured with JWKS URI."""
        return JWTVerifier(
            jwks_uri="https://test.example.com/.well-known/jwks.json",
            issuer="https://test.example.com",
            audience="https://api.example.com",
        )

    @pytest.fixture
    def mock_jwks_data(self, rsa_key_pair: RSAKeyPair) -> JWKSData:
        """Create mock JWKS data from RSA key pair."""
        from authlib.jose import JsonWebKey

        # Create JWK from the RSA public key
        jwk = JsonWebKey.import_key(rsa_key_pair.public_key)  # type: ignore
        jwk_data: JWKData = jwk.as_dict()  # type: ignore
        jwk_data["kid"] = "test-key-1"
        jwk_data["alg"] = "RS256"

        return {"keys": [jwk_data]}

    async def test_jwks_token_validation(
        self,
        rsa_key_pair: RSAKeyPair,
        jwks_provider: JWTVerifier,
        mock_jwks_data: JWKSData,
        httpx_mock: HTTPXMock,
    ):
        """Test token validation using JWKS URI."""
        httpx_mock.add_response(
            url="https://test.example.com/.well-known/jwks.json",
            json=mock_jwks_data,
        )

        username = "test-user"
        issuer = "https://test.example.com"
        audience = "https://api.example.com"

        token = rsa_key_pair.create_token(
            subject=username,
            issuer=issuer,
            audience=audience,
        )

        access_token = await jwks_provider.load_access_token(token)
        assert access_token is not None
        assert access_token.client_id == username

        # ensure the raw claims are present - #1398
        assert access_token.claims.get("sub") == username
        assert access_token.claims.get("iss") == issuer
        assert access_token.claims.get("aud") == audience

    async def test_jwks_token_validation_with_invalid_key(
        self,
        rsa_key_pair: RSAKeyPair,
        jwks_provider: JWTVerifier,
        mock_jwks_data: JWKSData,
        httpx_mock: HTTPXMock,
    ):
        httpx_mock.add_response(
            url="https://test.example.com/.well-known/jwks.json",
            json=mock_jwks_data,
        )
        token = RSAKeyPair.generate().create_token(
            subject="test-user",
            issuer="https://test.example.com",
            audience="https://api.example.com",
        )

        access_token = await jwks_provider.load_access_token(token)
        assert access_token is None

    async def test_jwks_token_validation_with_kid(
        self,
        rsa_key_pair: RSAKeyPair,
        jwks_provider: JWTVerifier,
        mock_jwks_data: JWKSData,
        httpx_mock: HTTPXMock,
    ):
        mock_jwks_data["keys"][0]["kid"] = "test-key-1"
        httpx_mock.add_response(
            url="https://test.example.com/.well-known/jwks.json",
            json=mock_jwks_data,
        )
        token = rsa_key_pair.create_token(
            subject="test-user",
            issuer="https://test.example.com",
            audience="https://api.example.com",
            kid="test-key-1",
        )

        access_token = await jwks_provider.load_access_token(token)
        assert access_token is not None
        assert access_token.client_id == "test-user"

    async def test_jwks_token_validation_with_kid_and_no_kid_in_token(
        self,
        rsa_key_pair: RSAKeyPair,
        jwks_provider: JWTVerifier,
        mock_jwks_data: JWKSData,
        httpx_mock: HTTPXMock,
    ):
        mock_jwks_data["keys"][0]["kid"] = "test-key-1"
        httpx_mock.add_response(
            url="https://test.example.com/.well-known/jwks.json",
            json=mock_jwks_data,
        )
        token = rsa_key_pair.create_token(
            subject="test-user",
            issuer="https://test.example.com",
            audience="https://api.example.com",
        )

        access_token = await jwks_provider.load_access_token(token)
        assert access_token is not None
        assert access_token.client_id == "test-user"

    async def test_jwks_token_validation_with_no_kid_and_kid_in_jwks(
        self,
        rsa_key_pair: RSAKeyPair,
        jwks_provider: JWTVerifier,
        mock_jwks_data: JWKSData,
        httpx_mock: HTTPXMock,
    ):
        mock_jwks_data["keys"][0]["kid"] = "test-key-1"
        httpx_mock.add_response(
            url="https://test.example.com/.well-known/jwks.json",
            json=mock_jwks_data,
        )
        token = rsa_key_pair.create_token(
            subject="test-user",
            issuer="https://test.example.com",
            audience="https://api.example.com",
        )

        access_token = await jwks_provider.load_access_token(token)
        assert access_token is not None
        assert access_token.client_id == "test-user"

    async def test_jwks_token_validation_with_kid_mismatch(
        self,
        rsa_key_pair: RSAKeyPair,
        jwks_provider: JWTVerifier,
        mock_jwks_data: JWKSData,
        httpx_mock: HTTPXMock,
    ):
        mock_jwks_data["keys"][0]["kid"] = "test-key-1"
        httpx_mock.add_response(
            url="https://test.example.com/.well-known/jwks.json",
            json=mock_jwks_data,
        )
        token = rsa_key_pair.create_token(
            subject="test-user",
            issuer="https://test.example.com",
            audience="https://api.example.com",
            kid="test-key-2",
        )

        access_token = await jwks_provider.load_access_token(token)
        assert access_token is None

    async def test_jwks_token_validation_with_multiple_keys_and_no_kid_in_token(
        self,
        rsa_key_pair: RSAKeyPair,
        jwks_provider: JWTVerifier,
        mock_jwks_data: JWKSData,
        httpx_mock: HTTPXMock,
    ):
        mock_jwks_data["keys"] = [
            {
                "kid": "test-key-1",
                "alg": "RS256",
            },
            {
                "kid": "test-key-2",
                "alg": "RS256",
            },
        ]

        httpx_mock.add_response(
            url="https://test.example.com/.well-known/jwks.json",
            json=mock_jwks_data,
        )
        token = rsa_key_pair.create_token(
            subject="test-user",
            issuer="https://test.example.com",
            audience="https://api.example.com",
        )

        access_token = await jwks_provider.load_access_token(token)
        assert access_token is None


class TestBearerToken:
    def test_initialization_with_public_key(self, rsa_key_pair: RSAKeyPair):
        """Test provider initialization with public key."""
        provider = JWTVerifier(
            public_key=rsa_key_pair.public_key, issuer="https://test.example.com"
        )

        assert provider.issuer == "https://test.example.com"
        assert provider.public_key is not None
        assert provider.jwks_uri is None

    def test_initialization_with_jwks_uri(self):
        """Test provider initialization with JWKS URI."""
        provider = JWTVerifier(
            jwks_uri="https://test.example.com/.well-known/jwks.json",
            issuer="https://test.example.com",
        )

        assert provider.issuer == "https://test.example.com"
        assert provider.jwks_uri == "https://test.example.com/.well-known/jwks.json"
        assert provider.public_key is None

    def test_initialization_requires_key_or_uri(self):
        """Test that either public_key or jwks_uri is required."""
        with pytest.raises(
            ValueError, match="Either public_key or jwks_uri must be provided"
        ):
            JWTVerifier(issuer="https://test.example.com")

    def test_initialization_rejects_both_key_and_uri(self, rsa_key_pair: RSAKeyPair):
        """Test that both public_key and jwks_uri cannot be provided."""
        with pytest.raises(
            ValueError, match="Provide either public_key or jwks_uri, not both"
        ):
            JWTVerifier(
                public_key=rsa_key_pair.public_key,
                jwks_uri="https://test.example.com/.well-known/jwks.json",
                issuer="https://test.example.com",
            )

    async def test_valid_token_validation(
        self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
    ):
        """Test validation of a valid token."""
        token = rsa_key_pair.create_token(
            subject="test-user",
            issuer="https://test.example.com",
            audience="https://api.example.com",
            scopes=["read", "write"],
        )

        access_token = await bearer_provider.load_access_token(token)

        assert access_token is not None
        assert access_token.client_id == "test-user"
        assert "read" in access_token.scopes
        assert "write" in access_token.scopes
        assert access_token.expires_at is not None

    async def test_expired_token_rejection(
        self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
    ):
        """Test rejection of expired tokens."""
        token = rsa_key_pair.create_token(
            subject="test-user",
            issuer="https://test.example.com",
            audience="https://api.example.com",
            expires_in_seconds=-3600,  # Expired 1 hour ago
        )

        access_token = await bearer_provider.load_access_token(token)
        assert access_token is None

    async def test_invalid_issuer_rejection(
        self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
    ):
        """Test rejection of tokens with invalid issuer."""
        token = rsa_key_pair.create_token(
            subject="test-user",
            issuer="https://evil.example.com",  # Wrong issuer
            audience="https://api.example.com",
        )

        access_token = await bearer_provider.load_access_token(token)
        assert access_token is None

    async def test_invalid_audience_rejection(
        self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
    ):
        """Test rejection of tokens with invalid audience."""
        token = rsa_key_pair.create_token(
            subject="test-user",
            issuer="https://test.example.com",
            audience="https://wrong-api.example.com",  # Wrong audience
        )

        access_token = await bearer_provider.load_access_token(token)
        assert access_token is None

    async def test_no_issuer_validation_when_none(self, rsa_key_pair: RSAKeyPair):
        """Test that issuer validation is skipped when provider has no issuer configured."""
        provider = JWTVerifier(
            public_key=rsa_key_pair.public_key,
            issuer=None,  # No issuer validation
        )

        token = rsa_key_pair.create_token(
            subject="test-user", issuer="https://any.example.com"
        )

        access_token = await provider.load_access_token(token)
        assert access_token is not None

    async def test_no_audience_validation_when_none(self, rsa_key_pair: RSAKeyPair):
        """Test that audience validation is skipped when provider has no audience configured."""
        provider = JWTVerifier(
            public_key=rsa_key_pair.public_key,
            issuer="https://test.example.com",
            audience=None,  # No audience validation
        )

        token = rsa_key_pair.create_token(
            subject="test-user",
            issuer="https://test.example.com",
            audience="https://any-api.example.com",
        )

        access_token = await provider.load_access_token(token)
        assert access_token is not None

    async def test_multiple_audiences_validation(self, rsa_key_pair: RSAKeyPair):
        """Test validation with multiple audiences in token."""
        provider = JWTVerifier(
            public_key=rsa_key_pair.public_key,
            issuer="https://test.example.com",
            audience="https://api.example.com",
        )

        token = rsa_key_pair.create_token(
            subject="test-user",
            issuer="https://test.example.com",
            additional_claims={
                "aud": ["https://api.example.com", "https://other-api.example.com"]
            },
        )

        access_token = await provider.load_access_token(token)
        assert access_token is not None

    async def test_provider_with_multiple_expected_audiences(
        self, rsa_key_pair: RSAKeyPair
    ):
        """Test provider configured with multiple expected audiences."""
        provider = JWTVerifier(
            public_key=rsa_key_pair.public_key,
            issuer="https://test.example.com",
            audience=["https://api.example.com", "https://other-api.example.com"],
        )

        # Token with single audience that matches one of the expected
        token1 = rsa_key_pair.create_token(
            subject="test-user",
            issuer="https://test.example.com",
            audience="https://api.example.com",
        )
        access_token1 = await provider.load_access_token(token1)
        assert access_token1 is not None

        # Token with multiple audiences, one of which matches
        token2 = rsa_key_pair.create_token(
            subject="test-user",
            issuer="https://test.example.com",
            additional_claims={
                "aud": ["https://api.example.com", "https://third-party.example.com"]
            },
        )
        access_token2 = await provider.load_access_token(token2)
        assert access_token2 is not None

        # Token with audience that doesn't match any expected
        token3 = rsa_key_pair.create_token(
            subject="test-user",
            issuer="https://test.example.com",
            audience="https://wrong-api.example.com",
        )
        access_token3 = await provider.load_access_token(token3)
        assert access_token3 is None

    async def test_scope_extraction_string(
        self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
    ):
        """Test scope extraction from space-separated string."""
        token = rsa_key_pair.create_token(
            subject="test-user",
            issuer="https://test.example.com",
            audience="https://api.example.com",
            scopes=["read", "write", "admin"],
        )

        access_token = await bearer_provider.load_access_token(token)

        assert access_token is not None
        assert set(access_token.scopes) == {"read", "write", "admin"}

    async def test_scope_extraction_list(
        self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
    ):
        """Test scope extraction from list format."""
        token = rsa_key_pair.create_token(
            subject="test-user",
            issuer="https://test.example.com",
            audience="https://api.example.com",
            additional_claims={"scope": ["read", "write"]},  # List format
        )

        access_token = await bearer_provider.load_access_token(token)

        assert access_token is not None
        assert set(access_token.scopes) == {"read", "write"}

    async def test_no_scopes(
        self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
    ):
        """Test token with no scopes."""
        token = rsa_key_pair.create_token(
            subject="test-user",
            issuer="https://test.example.com",
            audience="https://api.example.com",
            # No scopes
        )

        access_token = await bearer_provider.load_access_token(token)

        assert access_token is not None
        assert access_token.scopes == []

    async def test_scp_claim_extraction_string(
        self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
    ):
        """Test scope extraction from 'scp' claim with space-separated string."""
        token = rsa_key_pair.create_token(
            subject="test-user",
            issuer="https://test.example.com",
            audience="https://api.example.com",
            additional_claims={"scp": "read write admin"},  # 'scp' claim as string
        )

        access_token = await bearer_provider.load_access_token(token)

        assert access_token is not None
        assert set(access_token.scopes) == {"read", "write", "admin"}

    async def test_scp_claim_extraction_list(
        self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
    ):
        """Test scope extraction from 'scp' claim with list format."""
        token = rsa_key_pair.create_token(
            subject="test-user",
            issuer="https://test.example.com",
            audience="https://api.example.com",
            additional_claims={
                "scp": ["read", "write", "admin"]
            },  # 'scp' claim as list
        )

        access_token = await bearer_provider.load_access_token(token)

        assert access_token is not None
        assert set(access_token.scopes) == {"read", "write", "admin"}

    async def test_scope_precedence_over_scp(
        self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
    ):
        """Test that 'scope' claim takes precedence over 'scp' claim when both are present."""
        token = rsa_key_pair.create_token(
            subject="test-user",
            issuer="https://test.example.com",
            audience="https://api.example.com",
            additional_claims={
                "scope": "read write",  # Standard OAuth2 claim
                "scp": "admin delete",  # Should be ignored when 'scope' is present
            },
        )

        access_token = await bearer_provider.load_access_token(token)

        assert access_token is not None
        assert set(access_token.scopes) == {"read", "write"}  # Only 'scope' claim used

    async def test_malformed_token_rejection(self, bearer_provider: JWTVerifier):
        """Test rejection of malformed tokens."""
        malformed_tokens = [
            "not.a.jwt",
            "too.many.parts.here.invalid",
            "invalid-token",
            "",
            "header.body",  # Missing signature
        ]

        for token in malformed_tokens:
            access_token = await bearer_provider.load_access_token(token)
            assert access_token is None

    async def test_invalid_signature_rejection(
        self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
    ):
        """Test rejection of tokens with invalid signatures."""
        # Create a token with a different key pair
        other_key_pair = RSAKeyPair.generate()
        token = other_key_pair.create_token(
            subject="test-user",
            issuer="https://test.example.com",
            audience="https://api.example.com",
        )

        access_token = await bearer_provider.load_access_token(token)
        assert access_token is None

    async def test_client_id_fallback(
        self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
    ):
        """Test client_id extraction with fallback logic."""
        # Test with explicit client_id claim
        token = rsa_key_pair.create_token(
            subject="user123",
            issuer="https://test.example.com",
            audience="https://api.example.com",
            additional_claims={"client_id": "app456"},
        )

        access_token = await bearer_provider.load_access_token(token)
        assert access_token is not None
        assert access_token.client_id == "app456"  # Should prefer client_id over sub

    async def test_string_issuer_validation(self, rsa_key_pair: RSAKeyPair):
        """Test that string (non-URL) issuers are supported per RFC 7519."""
        # Create provider with string issuer
        provider = JWTVerifier(
            public_key=rsa_key_pair.public_key,
            issuer="my-service",  # String issuer, not a URL
        )

        # Create token with matching string issuer
        token = rsa_key_pair.create_token(
            subject="test-user",
            issuer="my-service",  # Same string issuer
        )

        access_token = await provider.load_access_token(token)
        assert access_token is not None
        assert access_token.client_id == "test-user"

    async def test_string_issuer_mismatch_rejection(self, rsa_key_pair: RSAKeyPair):
        """Test that mismatched string issuers are rejected."""
        # Create provider with one string issuer
        provider = JWTVerifier(
            public_key=rsa_key_pair.public_key,
            issuer="my-service",
        )

        # Create token with different string issuer
        token = rsa_key_pair.create_token(
            subject="test-user",
            issuer="other-service",  # Different string issuer
        )

        access_token = await provider.load_access_token(token)
        assert access_token is None

    async def test_url_issuer_still_works(self, rsa_key_pair: RSAKeyPair):
        """Test that URL issuers still work after the fix."""
        # Create provider with URL issuer
        provider = JWTVerifier(
            public_key=rsa_key_pair.public_key,
            issuer="https://my-auth-server.com",  # URL issuer
        )

        # Create token with matching URL issuer
        token = rsa_key_pair.create_token(
            subject="test-user",
            issuer="https://my-auth-server.com",  # Same URL issuer
        )

        access_token = await provider.load_access_token(token)
        assert access_token is not None
        assert access_token.client_id == "test-user"


class TestFastMCPBearerAuth:
    def test_bearer_auth(self):
        mcp = FastMCP(
            auth=JWTVerifier(issuer="https://test.example.com", public_key="abc")
        )
        assert isinstance(mcp.auth, JWTVerifier)

    async def test_unauthorized_access(self, mcp_server_url: str):
        with pytest.raises(httpx.HTTPStatusError) as exc_info:
            async with Client(mcp_server_url) as client:
                tools = await client.list_tools()  # noqa: F841
        assert exc_info.value.response.status_code == 401
        assert "tools" not in locals()

    async def test_authorized_access(self, mcp_server_url: str, bearer_token):
        async with Client(mcp_server_url, auth=BearerAuth(bearer_token)) as client:
            tools = await client.list_tools()  # noqa: F841
        assert tools

    async def test_invalid_token_raises_401(self, mcp_server_url: str):
        with pytest.raises(httpx.HTTPStatusError) as exc_info:
            async with Client(mcp_server_url, auth=BearerAuth("invalid")) as client:
                tools = await client.list_tools()  # noqa: F841
        assert exc_info.value.response.status_code == 401
        assert "tools" not in locals()

    async def test_expired_token(self, mcp_server_url: str, rsa_key_pair: RSAKeyPair):
        token = rsa_key_pair.create_token(
            subject="test-user",
            issuer="https://test.example.com",
            audience="https://api.example.com",
            expires_in_seconds=-3600,
        )

        with pytest.raises(httpx.HTTPStatusError) as exc_info:
            async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
                tools = await client.list_tools()  # noqa: F841
        assert exc_info.value.response.status_code == 401
        assert "tools" not in locals()

    async def test_token_with_bad_signature(self, mcp_server_url: str):
        rsa_key_pair = RSAKeyPair.generate()
        token = rsa_key_pair.create_token()

        with pytest.raises(httpx.HTTPStatusError) as exc_info:
            async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
                tools = await client.list_tools()  # noqa: F841
        assert exc_info.value.response.status_code == 401
        assert "tools" not in locals()

    async def test_token_with_insufficient_scopes(
        self, mcp_server_url: str, rsa_key_pair: RSAKeyPair
    ):
        token = rsa_key_pair.create_token(
            subject="test-user",
            issuer="https://test.example.com",
            audience="https://api.example.com",
            scopes=["read"],
        )

        with run_server_in_process(
            run_mcp_server,
            public_key=rsa_key_pair.public_key,
            auth_kwargs=dict(required_scopes=["read", "write"]),
            run_kwargs=dict(transport="http"),
        ) as url:
            mcp_server_url = f"{url}/mcp/"
            with pytest.raises(httpx.HTTPStatusError) as exc_info:
                async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
                    tools = await client.list_tools()  # noqa: F841
            # JWTVerifier returns 401 when verify_token returns None (invalid token)
            # This is correct behavior - when TokenVerifier.verify_token returns None,
            # it indicates the token is invalid (not just insufficient permissions)
            assert exc_info.value.response.status_code == 401
            assert "tools" not in locals()

    async def test_token_with_sufficient_scopes(
        self, mcp_server_url: str, rsa_key_pair: RSAKeyPair
    ):
        token = rsa_key_pair.create_token(
            subject="test-user",
            issuer="https://test.example.com",
            audience="https://api.example.com",
            scopes=["read", "write"],
        )

        with run_server_in_process(
            run_mcp_server,
            public_key=rsa_key_pair.public_key,
            auth_kwargs=dict(required_scopes=["read", "write"]),
            run_kwargs=dict(transport="http"),
        ) as url:
            mcp_server_url = f"{url}/mcp/"
            async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
                tools = await client.list_tools()
        assert tools


class TestJWTVerifierImport:
    """Test JWT token verifier can be imported and created."""

    def test_jwt_verifier_requires_pyjwt(self):
        """Test that JWTVerifier raises helpful error without PyJWT."""
        # Since PyJWT is likely installed in test environment, we'll just test construction
        from fastmcp.server.auth.providers.jwt import JWTVerifier

        # This should work if PyJWT is available
        try:
            verifier = JWTVerifier(public_key="dummy-key")
            assert verifier.public_key == "dummy-key"
            assert verifier.algorithm == "RS256"
        except ImportError as e:
            # If PyJWT not available, should get helpful error
            assert "PyJWT is required" in str(e)