History

Merge pull request #691 from RhysSullivan/fix/mcp-oauth-hardening

fix(oauth): MCP authorization spec hardening

Rhys Sullivan committed 6 months ago

Commit e42476a2 parents 63cdca3 c37e5a96 files · +738 −30Browse files

Unified · Split

6 files
  1. packages/core/sdk/src/oauth-discovery.test.ts +501 −4
  2. packages/core/sdk/src/oauth-discovery.ts +115 −14
  3. packages/core/sdk/src/oauth-helpers.test.ts +60 −0
  4. packages/core/sdk/src/oauth-helpers.ts +26 −5
  5. packages/core/sdk/src/oauth-service.ts +27 −7
  6. packages/core/sdk/src/oauth.ts +9 −0
packages/core/sdk/src/oauth-discovery.test.ts+501−4
@@ -4,6 +4,7 @@ import { Cause, Effect, Exit, Schema } from "effect"; · 3 unmodified lines
4import {4import {
5 OAuthDiscoveryError,5 OAuthDiscoveryError,
6 beginDynamicAuthorization,6 beginDynamicAuthorization,
7 canonicalResourceUrl,
7 discoverAuthorizationServerMetadata,8 discoverAuthorizationServerMetadata,
8 discoverProtectedResourceMetadata,9 discoverProtectedResourceMetadata,
9 registerDynamicClient,10 registerDynamicClient,
@@ -15,6 +16,7 @@ const DcrRequestBody = Schema.Struct({ · 5 unmodified lines
15 redirect_uris: Schema.Array(Schema.String),16 redirect_uris: Schema.Array(Schema.String),
16 token_endpoint_auth_method: Schema.String,17 token_endpoint_auth_method: Schema.String,
17 scope: Schema.optional(Schema.String),18 scope: Schema.optional(Schema.String),
19 client_uri: Schema.optional(Schema.String),
18});20});
19const decodeDcrRequestBody = Schema.decodeUnknownSync(Schema.fromJsonString(DcrRequestBody));21const decodeDcrRequestBody = Schema.decodeUnknownSync(Schema.fromJsonString(DcrRequestBody));
20 22
@@ -34,6 +36,18 @@ const installFetchRouter = ( · 13 unmodified lines
34 36
35const originalFetch = globalThis.fetch;37const originalFetch = globalThis.fetch;
36 38
39describe("canonicalResourceUrl", () => {
40 it("lowercases scheme + host, drops trailing slash, fragment, and query", () => {
41 expect(canonicalResourceUrl("https://API.Example.com/v1/mcp/")).toBe(
42 "https://api.example.com/v1/mcp",
43 );
44 expect(canonicalResourceUrl("HTTPS://api.example.com/v1/mcp?x=1#frag")).toBe(
45 "https://api.example.com/v1/mcp",
46 );
47 expect(canonicalResourceUrl("https://api.example.com/")).toBe("https://api.example.com");
48 });
49});
50
37describe("discoverProtectedResourceMetadata", () => {51describe("discoverProtectedResourceMetadata", () => {
38 afterEach(() => {52 afterEach(() => {
39 globalThis.fetch = originalFetch;53 globalThis.fetch = originalFetch;
@@ -350,7 +364,7 @@ describe("beginDynamicAuthorization", () => { · 310 unmodified lines
350 expect(result.state.resourceMetadata?.resource).toBe("https://backboard.railway.com");364 expect(result.state.resourceMetadata?.resource).toBe("https://backboard.railway.com");
351 });365 });
352 366
353 it("declares requested scopes in the DCR body so Auth0-style servers don't reject /authorize", async () => {367 it("declares requested scopes in the DCR body when caller passes them explicitly", async () => {
354 const { calls } = installFetchRouter([368 const { calls } = installFetchRouter([
355 {369 {
356 match: (u) => u === "https://mcp.grata.com/.well-known/oauth-protected-resource",370 match: (u) => u === "https://mcp.grata.com/.well-known/oauth-protected-resource",
@@ -381,7 +395,7 @@ describe("beginDynamicAuthorization", () => { · 24 unmodified lines
381 client_id: "grata-client-id",395 client_id: "grata-client-id",
382 redirect_uris: ["https://app.example/cb"],396 redirect_uris: ["https://app.example/cb"],
383 token_endpoint_auth_method: "none",397 token_endpoint_auth_method: "none",
384 scope: "openid profile email offline_access",398 scope: "openid offline_access",
385 }),399 }),
386 { status: 201, headers: { "content-type": "application/json" } },400 { status: 201, headers: { "content-type": "application/json" } },
387 ),401 ),
@@ -393,19 +407,502 @@ describe("beginDynamicAuthorization", () => { · 5 unmodified lines
393 endpoint: "https://mcp.grata.com/",407 endpoint: "https://mcp.grata.com/",
394 redirectUrl: "https://app.example/cb",408 redirectUrl: "https://app.example/cb",
395 state: "state-grata",409 state: "state-grata",
410 scopes: ["openid", "offline_access"],
396 }),411 }),
397 );412 );
398 413
399 const dcrCall = calls.find((c) => c.url === "https://mcp.grata.com/register");414 const dcrCall = calls.find((c) => c.url === "https://mcp.grata.com/register");
400 expect(dcrCall).toBeDefined();415 expect(dcrCall).toBeDefined();
401 const body = decodeDcrRequestBody(String(dcrCall!.init.body));416 const body = decodeDcrRequestBody(String(dcrCall!.init.body));
402 expect(body.scope).toBe("openid profile email offline_access");417 expect(body.scope).toBe("openid offline_access");
403 418
404 const authUrl = new URL(result.authorizationUrl);419 const authUrl = new URL(result.authorizationUrl);
405 expect(authUrl.searchParams.get("scope")).toBe("openid profile email offline_access");420 expect(authUrl.searchParams.get("scope")).toBe("openid offline_access");
406 expect(authUrl.searchParams.get("client_id")).toBe("grata-client-id");421 expect(authUrl.searchParams.get("client_id")).toBe("grata-client-id");
407 });422 });
408 423
424 it("requests only PRM scopes_supported when advertised (RFC 9728 §2 limited scope)", async () => {
425 const { calls } = installFetchRouter([
426 {
427 match: (u) => u === "https://api.example.com/.well-known/oauth-protected-resource/v1/mcp",
428 handle: () =>
429 new Response(
430 JSON.stringify({
431 resource: "https://api.example.com/v1/mcp",
432 authorization_servers: ["https://as.example.com"],
433 scopes_supported: ["mcp:read"],
434 }),
435 { status: 200, headers: { "content-type": "application/json" } },
436 ),
437 },
438 {
439 match: (u) => u === "https://as.example.com/.well-known/oauth-authorization-server",
440 handle: () =>
441 new Response(
442 JSON.stringify({
443 issuer: "https://as.example.com",
444 authorization_endpoint: "https://as.example.com/authorize",
445 token_endpoint: "https://as.example.com/token",
446 registration_endpoint: "https://as.example.com/register",
447 scopes_supported: ["openid", "profile", "mcp:read", "mcp:admin", "offline_access"],
448 response_types_supported: ["code"],
449 code_challenge_methods_supported: ["S256"],
450 }),
451 { status: 200, headers: { "content-type": "application/json" } },
452 ),
453 },
454 {
455 match: (u) => u === "https://as.example.com/register",
456 handle: () =>
457 new Response(
458 JSON.stringify({
459 client_id: "narrow-scope-client",
460 redirect_uris: ["https://app/cb"],
461 token_endpoint_auth_method: "none",
462 }),
463 { status: 201, headers: { "content-type": "application/json" } },
464 ),
465 },
466 ]);
467
468 const result = await Effect.runPromise(
469 beginDynamicAuthorization({
470 endpoint: "https://api.example.com/v1/mcp",
471 redirectUrl: "https://app/cb",
472 state: "s",
473 }),
474 );
475
476 const dcrCall = calls.find((c) => c.url === "https://as.example.com/register");
477 const body = decodeDcrRequestBody(String(dcrCall!.init.body));
478 expect(body.scope).toBe("mcp:read");
479
480 const authUrl = new URL(result.authorizationUrl);
481 expect(authUrl.searchParams.get("scope")).toBe("mcp:read");
482 });
483
484 it("requests empty scope when only AS-level scopes_supported is advertised (RFC 9728 §2)", async () => {
485 const { calls } = installFetchRouter([
486 {
487 match: (u) => u === "https://only-as.example.com/.well-known/oauth-protected-resource",
488 handle: () => new Response(null, { status: 404 }),
489 },
490 {
491 match: (u) => u === "https://only-as.example.com/.well-known/oauth-authorization-server",
492 handle: () =>
493 new Response(
494 JSON.stringify({
495 issuer: "https://only-as.example.com",
496 authorization_endpoint: "https://only-as.example.com/authorize",
497 token_endpoint: "https://only-as.example.com/token",
498 registration_endpoint: "https://only-as.example.com/register",
499 scopes_supported: ["openid", "profile", "admin", "offline_access"],
500 response_types_supported: ["code"],
501 code_challenge_methods_supported: ["S256"],
502 }),
503 { status: 200, headers: { "content-type": "application/json" } },
504 ),
505 },
506 {
507 match: (u) => u === "https://only-as.example.com/register",
508 handle: () =>
509 new Response(
510 JSON.stringify({
511 client_id: "no-scope-client",
512 redirect_uris: ["https://app/cb"],
513 token_endpoint_auth_method: "none",
514 }),
515 { status: 201, headers: { "content-type": "application/json" } },
516 ),
517 },
518 ]);
519
520 const result = await Effect.runPromise(
521 beginDynamicAuthorization({
522 endpoint: "https://only-as.example.com/",
523 redirectUrl: "https://app/cb",
524 state: "s",
525 }),
526 );
527
528 const dcrCall = calls.find((c) => c.url === "https://only-as.example.com/register");
529 const body = decodeDcrRequestBody(String(dcrCall!.init.body));
530 expect(body.scope).toBeUndefined();
531
532 const authUrl = new URL(result.authorizationUrl);
533 expect(authUrl.searchParams.get("scope")).toBe("");
534 });
535
536 it("includes RFC 8707 resource parameter on the authorization URL (PRM resource claim)", async () => {
537 installFetchRouter([
538 {
539 match: (u) => u === "https://api.example.com/.well-known/oauth-protected-resource/v1/mcp",
540 handle: () =>
541 new Response(
542 JSON.stringify({
543 resource: "https://api.example.com/canonical-id",
544 authorization_servers: ["https://api.example.com"],
545 }),
546 { status: 200, headers: { "content-type": "application/json" } },
547 ),
548 },
549 {
550 match: (u) => u === "https://api.example.com/.well-known/oauth-authorization-server",
551 handle: () =>
552 new Response(
553 JSON.stringify({
554 issuer: "https://api.example.com",
555 authorization_endpoint: "https://api.example.com/authorize",
556 token_endpoint: "https://api.example.com/token",
557 registration_endpoint: "https://api.example.com/register",
558 response_types_supported: ["code"],
559 code_challenge_methods_supported: ["S256"],
560 }),
561 { status: 200, headers: { "content-type": "application/json" } },
562 ),
563 },
564 {
565 match: (u) => u === "https://api.example.com/register",
566 handle: () =>
567 new Response(
568 JSON.stringify({
569 client_id: "res-client",
570 redirect_uris: ["https://app/cb"],
571 token_endpoint_auth_method: "none",
572 }),
573 { status: 201, headers: { "content-type": "application/json" } },
574 ),
575 },
576 ]);
577
578 const result = await Effect.runPromise(
579 beginDynamicAuthorization({
580 endpoint: "https://api.example.com/v1/mcp",
581 redirectUrl: "https://app/cb",
582 state: "s",
583 }),
584 );
585
586 const authUrl = new URL(result.authorizationUrl);
587 expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/canonical-id");
588 expect(result.state.resource).toBe("https://api.example.com/canonical-id");
589 });
590
591 it("falls back to canonical endpoint URL for the resource parameter when PRM is absent", async () => {
592 installFetchRouter([
593 {
594 match: (u) => u === "https://api.example.com/.well-known/oauth-protected-resource/v1/mcp",
595 handle: () => new Response(null, { status: 404 }),
596 },
597 {
598 match: (u) => u === "https://api.example.com/.well-known/oauth-protected-resource",
599 handle: () => new Response(null, { status: 404 }),
600 },
601 {
602 match: (u) => u === "https://api.example.com/.well-known/oauth-authorization-server",
603 handle: () =>
604 new Response(
605 JSON.stringify({
606 issuer: "https://api.example.com",
607 authorization_endpoint: "https://api.example.com/authorize",
608 token_endpoint: "https://api.example.com/token",
609 registration_endpoint: "https://api.example.com/register",
610 response_types_supported: ["code"],
611 code_challenge_methods_supported: ["S256"],
612 }),
613 { status: 200, headers: { "content-type": "application/json" } },
614 ),
615 },
616 {
617 match: (u) => u === "https://api.example.com/register",
618 handle: () =>
619 new Response(
620 JSON.stringify({
621 client_id: "ep-client",
622 redirect_uris: ["https://app/cb"],
623 token_endpoint_auth_method: "none",
624 }),
625 { status: 201, headers: { "content-type": "application/json" } },
626 ),
627 },
628 ]);
629
630 const result = await Effect.runPromise(
631 beginDynamicAuthorization({
632 endpoint: "https://API.example.com/v1/mcp/",
633 redirectUrl: "https://app/cb",
634 state: "s",
635 }),
636 );
637
638 const authUrl = new URL(result.authorizationUrl);
639 expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/v1/mcp");
640 });
641
642 it("includes client_uri in the DCR body (RFC 7591 §2 RECOMMENDED)", async () => {
643 const { calls } = installFetchRouter([
644 {
645 match: (u) => u === "https://only-as.example.com/.well-known/oauth-protected-resource",
646 handle: () => new Response(null, { status: 404 }),
647 },
648 {
649 match: (u) => u === "https://only-as.example.com/.well-known/oauth-authorization-server",
650 handle: () =>
651 new Response(
652 JSON.stringify({
653 issuer: "https://only-as.example.com",
654 authorization_endpoint: "https://only-as.example.com/authorize",
655 token_endpoint: "https://only-as.example.com/token",
656 registration_endpoint: "https://only-as.example.com/register",
657 response_types_supported: ["code"],
658 code_challenge_methods_supported: ["S256"],
659 }),
660 { status: 200, headers: { "content-type": "application/json" } },
661 ),
662 },
663 {
664 match: (u) => u === "https://only-as.example.com/register",
665 handle: () =>
666 new Response(
667 JSON.stringify({
668 client_id: "uri-client",
669 redirect_uris: ["https://app/cb"],
670 token_endpoint_auth_method: "none",
671 }),
672 { status: 201, headers: { "content-type": "application/json" } },
673 ),
674 },
675 ]);
676
677 await Effect.runPromise(
678 beginDynamicAuthorization({
679 endpoint: "https://only-as.example.com/",
680 redirectUrl: "https://app/cb",
681 state: "s",
682 }),
683 );
684
685 const dcrCall = calls.find((c) => c.url === "https://only-as.example.com/register");
686 const body = decodeDcrRequestBody(String(dcrCall!.init.body));
687 expect(body.client_uri).toBe("https://executor.sh");
688 });
689
690 it("negotiates client_secret_post when the AS does not advertise 'none' (Clay-style)", async () => {
691 const { calls } = installFetchRouter([
692 {
693 match: (u) => u === "https://api.clay.com/.well-known/oauth-protected-resource/v3/mcp",
694 handle: () =>
695 new Response(
696 JSON.stringify({
697 resource: "https://api.clay.com/v3/mcp",
698 authorization_servers: ["https://api.clay.com"],
699 scopes_supported: ["mcp"],
700 }),
701 { status: 200, headers: { "content-type": "application/json" } },
702 ),
703 },
704 {
705 match: (u) => u === "https://api.clay.com/.well-known/oauth-authorization-server",
706 handle: () =>
707 new Response(
708 JSON.stringify({
709 issuer: "https://api.clay.com",
710 authorization_endpoint: "https://api.clay.com/authorize",
711 token_endpoint: "https://api.clay.com/token",
712 registration_endpoint: "https://api.clay.com/register",
713 token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"],
714 response_types_supported: ["code"],
715 code_challenge_methods_supported: ["S256"],
716 }),
717 { status: 200, headers: { "content-type": "application/json" } },
718 ),
719 },
720 {
721 match: (u) => u === "https://api.clay.com/register",
722 handle: () =>
723 new Response(
724 JSON.stringify({
725 client_id: "clay-id",
726 client_secret: "clay-secret",
727 redirect_uris: ["https://app/cb"],
728 token_endpoint_auth_method: "client_secret_post",
729 }),
730 { status: 201, headers: { "content-type": "application/json" } },
731 ),
732 },
733 ]);
734
735 await Effect.runPromise(
736 beginDynamicAuthorization({
737 endpoint: "https://api.clay.com/v3/mcp",
738 redirectUrl: "https://app/cb",
739 state: "s",
740 }),
741 );
742
743 const dcrCall = calls.find((c) => c.url === "https://api.clay.com/register");
744 const body = decodeDcrRequestBody(String(dcrCall!.init.body));
745 expect(body.token_endpoint_auth_method).toBe("client_secret_post");
746 });
747
748 it("fails with a clear error when the AS advertises only auth methods we don't support", async () => {
749 installFetchRouter([
750 {
751 match: (u) => u === "https://jwt-only.example.com/.well-known/oauth-protected-resource",
752 handle: () => new Response(null, { status: 404 }),
753 },
754 {
755 match: (u) => u === "https://jwt-only.example.com/.well-known/oauth-authorization-server",
756 handle: () =>
757 new Response(
758 JSON.stringify({
759 issuer: "https://jwt-only.example.com",
760 authorization_endpoint: "https://jwt-only.example.com/authorize",
761 token_endpoint: "https://jwt-only.example.com/token",
762 registration_endpoint: "https://jwt-only.example.com/register",
763 token_endpoint_auth_methods_supported: ["private_key_jwt"],
764 response_types_supported: ["code"],
765 code_challenge_methods_supported: ["S256"],
766 }),
767 { status: 200, headers: { "content-type": "application/json" } },
768 ),
769 },
770 ]);
771
772 const exit = await Effect.runPromiseExit(
773 beginDynamicAuthorization({
774 endpoint: "https://jwt-only.example.com/",
775 redirectUrl: "https://app/cb",
776 state: "s",
777 }),
778 );
779 expect(Exit.isFailure(exit)).toBe(true);
780 if (!Exit.isFailure(exit)) return;
781 const reason = exit.cause.reasons.find(Cause.isFailReason);
782 expect(reason?.error).toEqual(
783 expect.objectContaining({
784 _tag: "OAuthDiscoveryError",
785 message: expect.stringMatching(/usable token_endpoint_auth_method/),
786 }),
787 );
788 });
789
790 it("falls through to a later authorization_servers entry when the first has no metadata", async () => {
791 installFetchRouter([
792 {
793 match: (u) => u === "https://multi-as.example.com/.well-known/oauth-protected-resource/api",
794 handle: () =>
795 new Response(
796 JSON.stringify({
797 resource: "https://multi-as.example.com/api",
798 authorization_servers: ["https://primary.example.com", "https://backup.example.com"],
799 }),
800 { status: 200, headers: { "content-type": "application/json" } },
801 ),
802 },
803 {
804 match: (u) => u === "https://primary.example.com/.well-known/oauth-authorization-server",
805 handle: () => new Response(null, { status: 404 }),
806 },
807 {
808 match: (u) => u === "https://primary.example.com/.well-known/openid-configuration",
809 handle: () => new Response(null, { status: 404 }),
810 },
811 {
812 match: (u) => u === "https://backup.example.com/.well-known/oauth-authorization-server",
813 handle: () =>
814 new Response(
815 JSON.stringify({
816 issuer: "https://backup.example.com",
817 authorization_endpoint: "https://backup.example.com/authorize",
818 token_endpoint: "https://backup.example.com/token",
819 registration_endpoint: "https://backup.example.com/register",
820 response_types_supported: ["code"],
821 code_challenge_methods_supported: ["S256"],
822 }),
823 { status: 200, headers: { "content-type": "application/json" } },
824 ),
825 },
826 {
827 match: (u) => u === "https://backup.example.com/register",
828 handle: () =>
829 new Response(
830 JSON.stringify({
831 client_id: "backup-client",
832 redirect_uris: ["https://app/cb"],
833 token_endpoint_auth_method: "none",
834 }),
835 { status: 201, headers: { "content-type": "application/json" } },
836 ),
837 },
838 ]);
839
840 const result = await Effect.runPromise(
841 beginDynamicAuthorization({
842 endpoint: "https://multi-as.example.com/api",
843 redirectUrl: "https://app/cb",
844 state: "s",
845 }),
846 );
847
848 expect(result.state.authorizationServerUrl).toBe("https://backup.example.com");
849 expect(result.state.clientInformation.client_id).toBe("backup-client");
850 });
851
852 it("propagates AS error code + description on DCR failure (RFC 7591 §3.2.2)", async () => {
853 installFetchRouter([
854 {
855 match: (u) => u === "https://errd.example.com/.well-known/oauth-protected-resource",
856 handle: () => new Response(null, { status: 404 }),
857 },
858 {
859 match: (u) => u === "https://errd.example.com/.well-known/oauth-authorization-server",
860 handle: () =>
861 new Response(
862 JSON.stringify({
863 issuer: "https://errd.example.com",
864 authorization_endpoint: "https://errd.example.com/authorize",
865 token_endpoint: "https://errd.example.com/token",
866 registration_endpoint: "https://errd.example.com/register",
867 response_types_supported: ["code"],
868 code_challenge_methods_supported: ["S256"],
869 }),
870 { status: 200, headers: { "content-type": "application/json" } },
871 ),
872 },
873 {
874 match: (u) => u === "https://errd.example.com/register",
875 handle: () =>
876 new Response(
877 JSON.stringify({
878 error: "invalid_redirect_uri",
879 error_description: "redirect_uri must be from an allowed domain",
880 }),
881 { status: 400, headers: { "content-type": "application/json" } },
882 ),
883 },
884 ]);
885
886 const exit = await Effect.runPromiseExit(
887 beginDynamicAuthorization({
888 endpoint: "https://errd.example.com/",
889 redirectUrl: "https://app/cb",
890 state: "s",
891 }),
892 );
893 expect(Exit.isFailure(exit)).toBe(true);
894 if (!Exit.isFailure(exit)) return;
895 const reason = exit.cause.reasons.find(Cause.isFailReason);
896 expect(reason?.error).toEqual(
897 expect.objectContaining({
898 _tag: "OAuthDiscoveryError",
899 status: 400,
900 error: "invalid_redirect_uri",
901 errorDescription: "redirect_uri must be from an allowed domain",
902 }),
903 );
904 });
905
409 it("skips discovery + DCR when previousState is provided", async () => {906 it("skips discovery + DCR when previousState is provided", async () => {
410 const { calls } = installFetchRouter([]);907 const { calls } = installFetchRouter([]);
411 908
packages/core/sdk/src/oauth-discovery.ts+115−14
@@ -40,6 +40,12 @@ import { · 39 unmodified lines
40export class OAuthDiscoveryError extends Data.TaggedError("OAuthDiscoveryError")<{40export class OAuthDiscoveryError extends Data.TaggedError("OAuthDiscoveryError")<{
41 readonly message: string;41 readonly message: string;
42 readonly status?: number;42 readonly status?: number;
43 /** RFC 6749 §5.2 / RFC 7591 §3.2.2 error code, when the AS returned
44 * one (`invalid_client_metadata`, `invalid_redirect_uri`, ...). Lets
45 * the HTTP edge surface a structured error to the UI rather than
46 * swallow the AS's response into a generic message string. */
47 readonly error?: string;
48 readonly errorDescription?: string;
43 readonly cause?: unknown;49 readonly cause?: unknown;
44}> {}50}> {}
45 51
@@ -523,6 +529,8 @@ export const registerDynamicClient = ( · 477 unmodified lines
523 err.error_description ? ` — ${err.error_description}` : ""529 err.error_description ? ` — ${err.error_description}` : ""
524 }`,530 }`,
525 status: err.status,531 status: err.status,
532 error: err.error,
533 errorDescription: err.error_description,
526 cause: err,534 cause: err,
527 }),535 }),
528 ),536 ),
@@ -537,6 +545,44 @@ export const registerDynamicClient = ( · 8 unmodified lines
537 }),545 }),
538 );546 );
539 547
548// ---------------------------------------------------------------------------
549// RFC 8707 — Resource Indicator canonicalisation
550//
551// MCP Authorization 2025-06-18 requires `resource` on /authorize and /token
552// requests. RFC 8707 §2 says the value is "an absolute URI" identifying the
553// protected resource — same scheme + host + (optional) path, no fragment,
554// no query, lowercased scheme/host.
555// ---------------------------------------------------------------------------
556
557export const canonicalResourceUrl = (value: string): string => {
558 const url = new URL(value);
559 const scheme = url.protocol.toLowerCase();
560 const host = url.host.toLowerCase();
561 const path = url.pathname.replace(/\/+$/, "");
562 return `${scheme}//${host}${path}`;
563};
564
565// ---------------------------------------------------------------------------
566// Token-endpoint auth method negotiation
567//
568// OAuth 2.1 §2.4 leaves the choice to the client; our preference order is
569// security-first: PKCE-only public client > client_secret_post >
570// client_secret_basic. Servers like Clay only advertise the secret variants;
571// servers like most MCP examples only advertise `none`.
572// ---------------------------------------------------------------------------
573
574const SUPPORTED_DCR_AUTH_METHODS = ["none", "client_secret_post", "client_secret_basic"] as const;
575
576type DcrAuthMethod = (typeof SUPPORTED_DCR_AUTH_METHODS)[number];
577
578const negotiateAuthMethod = (advertised: readonly string[] | undefined): DcrAuthMethod | null => {
579 if (!advertised || advertised.length === 0) return "none";
580 for (const candidate of SUPPORTED_DCR_AUTH_METHODS) {
581 if (advertised.includes(candidate)) return candidate;
582 }
583 return null;
584};
585
540// ---------------------------------------------------------------------------586// ---------------------------------------------------------------------------
541// Convenience: begin the full dynamic flow in one call587// Convenience: begin the full dynamic flow in one call
542// ---------------------------------------------------------------------------588// ---------------------------------------------------------------------------
@@ -548,6 +594,12 @@ export interface DynamicAuthorizationState { · 5 unmodified lines
548 readonly authorizationServerMetadataUrl: string;594 readonly authorizationServerMetadataUrl: string;
549 readonly authorizationServerMetadata: OAuthAuthorizationServerMetadata;595 readonly authorizationServerMetadata: OAuthAuthorizationServerMetadata;
550 readonly clientInformation: OAuthClientInformation;596 readonly clientInformation: OAuthClientInformation;
597 /** RFC 8707 canonical resource URL passed on /authorize and persisted
598 * for the matching /token + refresh calls. */
599 readonly resource: string;
600 /** Scopes ultimately requested at /authorize. Persisted so refresh
601 * can replay the same set. */
602 readonly scopes: readonly string[];
551}603}
552 604
553export interface DynamicAuthorizationStartResult {605export interface DynamicAuthorizationStartResult {
@@ -604,27 +656,48 @@ export const beginDynamicAuthorization = ( · 50 unmodified lines
604 : null656 : null
605 : yield* discoverProtectedResourceMetadata(input.endpoint, options);657 : yield* discoverProtectedResourceMetadata(input.endpoint, options);
606 658
607 const authorizationServerUrl = (() => {659 // RFC 9728 allows multiple authorization_servers — try each in
608 if (prior.authorizationServerUrl) return prior.authorizationServerUrl;660 // listed order. Fall back to the endpoint's origin only when no
609 const fromResource = resource && resource.metadata.authorization_servers?.[0];661 // PRM is advertised.
610 if (fromResource) return fromResource;662 const candidateAuthorizationServerUrls: readonly string[] = (() => {
663 if (prior.authorizationServerUrl) return [prior.authorizationServerUrl];
664 const fromResource = resource?.metadata.authorization_servers ?? [];
665 if (fromResource.length > 0) return fromResource;
611 const u = new URL(input.endpoint);666 const u = new URL(input.endpoint);
612 return `${u.protocol}//${u.host}`;667 return [`${u.protocol}//${u.host}`];
613 })();668 })();
614 669
615 const authServer =670 const priorAuthServer =
616 prior.authorizationServerMetadata && prior.authorizationServerMetadataUrl671 prior.authorizationServerMetadata && prior.authorizationServerMetadataUrl
617 ? {672 ? {
618 metadata: prior.authorizationServerMetadata,673 metadata: prior.authorizationServerMetadata,
619 metadataUrl: prior.authorizationServerMetadataUrl,674 metadataUrl: prior.authorizationServerMetadataUrl,
675 url: prior.authorizationServerUrl ?? candidateAuthorizationServerUrls[0]!,
620 }676 }
621 : yield* discoverAuthorizationServerMetadata(authorizationServerUrl, options);677 : null;
678
679 const discovered = priorAuthServer
680 ? priorAuthServer
681 : yield* (() => {
682 const tried: string[] = [];
683 return Effect.gen(function* () {
684 for (const candidate of candidateAuthorizationServerUrls) {
685 tried.push(candidate);
686 const md = yield* discoverAuthorizationServerMetadata(candidate, options).pipe(
687 Effect.catchTag("OAuthDiscoveryError", () => Effect.succeed(null)),
688 );
689 if (md) {
690 return { metadata: md.metadata, metadataUrl: md.metadataUrl, url: candidate };
691 }
692 }
693 return yield* new OAuthDiscoveryError({
694 message: `No OAuth authorization server metadata found (tried: ${tried.join(", ")})`,
695 });
696 });
697 })();
622 698
623 if (!authServer) {699 const authServer = { metadata: discovered.metadata, metadataUrl: discovered.metadataUrl };
624 return yield* new OAuthDiscoveryError({700 const authorizationServerUrl = discovered.url;
625 message: `No OAuth authorization server metadata at ${authorizationServerUrl}`,
626 });
627 }
628 701
629 const pkceMethods = authServer.metadata.code_challenge_methods_supported ?? [];702 const pkceMethods = authServer.metadata.code_challenge_methods_supported ?? [];
630 if (pkceMethods.length > 0 && !pkceMethods.includes("S256")) {703 if (pkceMethods.length > 0 && !pkceMethods.includes("S256")) {
@@ -640,13 +713,36 @@ export const beginDynamicAuthorization = ( · 9 unmodified lines
640 });713 });
641 }714 }
642 715
643 const scopes = input.scopes ?? authServer.metadata.scopes_supported ?? [];716 // RFC 9728 §2: PRM `scopes_supported` is the resource-scoped list and is
717 // authoritative when present. AS-level `scopes_supported` is global and
718 // (per RFC 9728 §2) "not meant to indicate that an OAuth client should
719 // request all scopes in the list", so we don't auto-expand it. When only
720 // AS-level scopes are advertised we request none and let the AS apply
721 // its default — callers wanting refresh tokens / specific scopes pass
722 // them explicitly via `input.scopes`.
723 const scopes: readonly string[] =
724 input.scopes ??
725 (resource?.metadata.scopes_supported && resource.metadata.scopes_supported.length > 0
726 ? resource.metadata.scopes_supported
727 : []);
728
729 const negotiatedAuthMethod = negotiateAuthMethod(
730 authServer.metadata.token_endpoint_auth_methods_supported,
731 );
732 if (!negotiatedAuthMethod) {
733 return yield* new OAuthDiscoveryError({
734 message: `Authorization server does not support a usable token_endpoint_auth_method (advertised: ${(
735 authServer.metadata.token_endpoint_auth_methods_supported ?? []
736 ).join(", ")})`,
737 });
738 }
644 739
645 const baseClientMetadata: DynamicClientMetadata = {740 const baseClientMetadata: DynamicClientMetadata = {
646 grant_types: ["authorization_code", "refresh_token"],741 grant_types: ["authorization_code", "refresh_token"],
647 response_types: ["code"],742 response_types: ["code"],
648 token_endpoint_auth_method: "none",743 token_endpoint_auth_method: negotiatedAuthMethod,
649 client_name: "Executor",744 client_name: "Executor",
745 client_uri: "https://executor.sh",
650 ...(scopes.length > 0 ? { scope: scopes.join(" ") } : {}),746 ...(scopes.length > 0 ? { scope: scopes.join(" ") } : {}),
651 ...(input.clientMetadata ?? {}),747 ...(input.clientMetadata ?? {}),
652 redirect_uris: input.clientMetadata?.redirect_uris ?? [input.redirectUrl],748 redirect_uris: input.clientMetadata?.redirect_uris ?? [input.redirectUrl],
@@ -670,6 +766,8 @@ export const beginDynamicAuthorization = ( · 17 unmodified lines
670 );766 );
671 })());767 })());
672 768
769 const resourceValue = resource?.metadata.resource ?? canonicalResourceUrl(input.endpoint);
770
673 const codeVerifier = createPkceCodeVerifier();771 const codeVerifier = createPkceCodeVerifier();
674 const codeChallenge = yield* Effect.promise(() => createPkceCodeChallenge(codeVerifier));772 const codeChallenge = yield* Effect.promise(() => createPkceCodeChallenge(codeVerifier));
675 773
@@ -680,6 +778,7 @@ export const beginDynamicAuthorization = ( · 4 unmodified lines
680 scopes,778 scopes,
681 state: input.state,779 state: input.state,
682 codeChallenge,780 codeChallenge,
781 resource: resourceValue,
683 });782 });
684 783
685 return {784 return {
@@ -692,6 +791,8 @@ export const beginDynamicAuthorization = ( · 6 unmodified lines
692 authorizationServerMetadataUrl: authServer.metadataUrl,791 authorizationServerMetadataUrl: authServer.metadataUrl,
693 authorizationServerMetadata: authServer.metadata,792 authorizationServerMetadata: authServer.metadata,
694 clientInformation,793 clientInformation,
794 resource: resourceValue,
795 scopes,
695 },796 },
696 };797 };
697 });798 });
packages/core/sdk/src/oauth-helpers.test.ts+60−0
@@ -117,6 +117,21 @@ describe("buildAuthorizationUrl", () => { · 116 unmodified lines
117 expect(url.searchParams.get("tenant")).toBe("acme");117 expect(url.searchParams.get("tenant")).toBe("acme");
118 expect(url.searchParams.get("client_id")).toBe("client-123");118 expect(url.searchParams.get("client_id")).toBe("client-123");
119 });119 });
120
121 it("includes RFC 8707 resource indicator when provided", () => {
122 const url = new URL(
123 buildAuthorizationUrl({
124 ...baseInput,
125 resource: "https://api.example.com/v1/mcp",
126 }),
127 );
128 expect(url.searchParams.get("resource")).toBe("https://api.example.com/v1/mcp");
129 });
130
131 it("omits resource parameter when not provided", () => {
132 const url = new URL(buildAuthorizationUrl(baseInput));
133 expect(url.searchParams.has("resource")).toBe(false);
134 });
120});135});
121 136
122// ---------------------------------------------------------------------------137// ---------------------------------------------------------------------------
@@ -184,6 +199,37 @@ describe("exchangeAuthorizationCode", () => { · 61 unmodified lines
184 expect(body.get("code")).toBe("abc");199 expect(body.get("code")).toBe("abc");
185 });200 });
186 201
202 it("includes RFC 8707 resource parameter on the token request when provided", async () => {
203 const { calls } = captureFetch(jsonResponse(200, validBody));
204 await Effect.runPromise(
205 exchangeAuthorizationCode({
206 tokenUrl: "https://example.com/token",
207 clientId: "cid",
208 redirectUrl: "https://app.example.com/cb",
209 codeVerifier: "verifier",
210 code: "abc",
211 resource: "https://api.example.com/v1/mcp",
212 }),
213 );
214 const body = calls[0]!.init.body as URLSearchParams;
215 expect(body.get("resource")).toBe("https://api.example.com/v1/mcp");
216 });
217
218 it("omits resource parameter when not provided", async () => {
219 const { calls } = captureFetch(jsonResponse(200, validBody));
220 await Effect.runPromise(
221 exchangeAuthorizationCode({
222 tokenUrl: "https://example.com/token",
223 clientId: "cid",
224 redirectUrl: "https://app.example.com/cb",
225 codeVerifier: "verifier",
226 code: "abc",
227 }),
228 );
229 const body = calls[0]!.init.body as URLSearchParams;
230 expect(body.has("resource")).toBe(false);
231 });
232
187 it("omits client_secret when none is provided (public clients with PKCE)", async () => {233 it("omits client_secret when none is provided (public clients with PKCE)", async () => {
188 const { calls } = captureFetch(jsonResponse(200, validBody));234 const { calls } = captureFetch(jsonResponse(200, validBody));
189 await Effect.runPromise(235 await Effect.runPromise(
@@ -456,6 +502,20 @@ describe("refreshAccessToken", () => { · 266 unmodified lines
456 expect(body.has("scope")).toBe(false);502 expect(body.has("scope")).toBe(false);
457 });503 });
458 504
505 it("includes RFC 8707 resource parameter on refresh requests when provided", async () => {
506 const { calls } = captureFetch(jsonResponse(200, validBody));
507 await Effect.runPromise(
508 refreshAccessToken({
509 tokenUrl: "https://example.com/token",
510 clientId: "cid",
511 refreshToken: "old",
512 resource: "https://api.example.com/v1/mcp",
513 }),
514 );
515 const body = calls[0]!.init.body as URLSearchParams;
516 expect(body.get("resource")).toBe("https://api.example.com/v1/mcp");
517 });
518
459 it("strips refreshed id_tokens whose iss does not match AS metadata", async () => {519 it("strips refreshed id_tokens whose iss does not match AS metadata", async () => {
460 captureFetch(520 captureFetch(
461 jsonResponse(200, {521 jsonResponse(200, {
packages/core/sdk/src/oauth-helpers.ts+26−5
@@ -80,6 +80,10 @@ export type BuildAuthorizationUrlInput = { · 79 unmodified lines
80 readonly codeChallenge: string;80 readonly codeChallenge: string;
81 /** Separator between scopes. RFC 6749 says space; some providers use comma. */81 /** Separator between scopes. RFC 6749 says space; some providers use comma. */
82 readonly scopeSeparator?: string;82 readonly scopeSeparator?: string;
83 /** RFC 8707 Resource Indicator. MCP Authorization 2025-06-18 §"Resource
84 * Parameter Implementation" requires clients to send this on every
85 * authorization request, regardless of AS support. */
86 readonly resource?: string;
83 /** Provider-specific extras (e.g. Google's `access_type=offline`). */87 /** Provider-specific extras (e.g. Google's `access_type=offline`). */
84 readonly extraParams?: Readonly<Record<string, string>>;88 readonly extraParams?: Readonly<Record<string, string>>;
85};89};
@@ -96,6 +100,9 @@ export const buildAuthorizationUrl = (input: BuildAuthorizationUrlInput): string · 10 unmodified lines
96 url.searchParams.set("state", input.state);100 url.searchParams.set("state", input.state);
97 url.searchParams.set("code_challenge_method", "S256");101 url.searchParams.set("code_challenge_method", "S256");
98 url.searchParams.set("code_challenge", input.codeChallenge);102 url.searchParams.set("code_challenge", input.codeChallenge);
103 if (input.resource) {
104 url.searchParams.set("resource", input.resource);
105 }
99 if (input.extraParams) {106 if (input.extraParams) {
100 for (const [k, v] of Object.entries(input.extraParams)) {107 for (const [k, v] of Object.entries(input.extraParams)) {
101 url.searchParams.set(k, v);108 url.searchParams.set(k, v);
@@ -261,6 +268,10 @@ export type ExchangeAuthorizationCodeInput = { · 159 unmodified lines
261 readonly code: string;268 readonly code: string;
262 readonly clientAuth?: ClientAuthMethod;269 readonly clientAuth?: ClientAuthMethod;
263 readonly idTokenSigningAlgValuesSupported?: readonly string[];270 readonly idTokenSigningAlgValuesSupported?: readonly string[];
271 /** RFC 8707 Resource Indicator. MCP Auth spec MUST-requires this on
272 * the token request when the client knows the resource it intends
273 * to call. */
274 readonly resource?: string;
264 readonly timeoutMs?: number;275 readonly timeoutMs?: number;
265};276};
266 277
@@ -284,6 +295,9 @@ export const exchangeAuthorizationCode = ( · 17 unmodified lines
284 redirect_uri: input.redirectUrl,295 redirect_uri: input.redirectUrl,
285 code_verifier: input.codeVerifier,296 code_verifier: input.codeVerifier,
286 });297 });
298 if (input.resource) {
299 params.set("resource", input.resource);
300 }
287 const response = await oauth.genericTokenEndpointRequest(301 const response = await oauth.genericTokenEndpointRequest(
288 as,302 as,
289 client,303 client,
@@ -350,6 +364,10 @@ export type RefreshAccessTokenInput = { · 60 unmodified lines
350 readonly scopeSeparator?: string;364 readonly scopeSeparator?: string;
351 readonly clientAuth?: ClientAuthMethod;365 readonly clientAuth?: ClientAuthMethod;
352 readonly idTokenSigningAlgValuesSupported?: readonly string[];366 readonly idTokenSigningAlgValuesSupported?: readonly string[];
367 /** RFC 8707 Resource Indicator — MCP spec MUST-requires this on
368 * refresh requests so the new access token's audience is bound to
369 * the same resource. */
370 readonly resource?: string;
353 readonly timeoutMs?: number;371 readonly timeoutMs?: number;
354};372};
355 373
@@ -363,12 +381,15 @@ export const refreshAccessToken = ( · 7 unmodified lines
363 });381 });
364 const client: oauth.Client = { client_id: input.clientId };382 const client: oauth.Client = { client_id: input.clientId };
365 const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body");383 const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body");
384 const extraParams = new URLSearchParams();
385 if (input.scopes && input.scopes.length > 0) {
386 extraParams.set("scope", input.scopes.join(input.scopeSeparator ?? " "));
387 }
388 if (input.resource) {
389 extraParams.set("resource", input.resource);
390 }
366 const additionalParameters =391 const additionalParameters =
367 input.scopes && input.scopes.length > 0392 Array.from(extraParams.keys()).length > 0 ? extraParams : undefined;
368 ? new URLSearchParams({
369 scope: input.scopes.join(input.scopeSeparator ?? " "),
370 })
371 : undefined;
372 const response = await oauth.refreshTokenGrantRequest(393 const response = await oauth.refreshTokenGrantRequest(
373 as,394 as,
374 client,395 client,
packages/core/sdk/src/oauth-service.ts+27−7
@@ -107,6 +107,7 @@ const DynamicDcrSessionPayload = Schema.Struct({ · 106 unmodified lines
107 resourceMetadataUrl: Schema.NullOr(Schema.String),107 resourceMetadataUrl: Schema.NullOr(Schema.String),
108 resourceMetadata: Schema.NullOr(Schema.Record(Schema.String, Schema.Unknown)),108 resourceMetadata: Schema.NullOr(Schema.Record(Schema.String, Schema.Unknown)),
109 scopes: Schema.Array(Schema.String),109 scopes: Schema.Array(Schema.String),
110 resource: Schema.NullOr(Schema.String).pipe(Schema.withDecodingDefaultType(Effect.succeed(null))),
110});111});
111 112
112const AuthorizationCodeSessionPayload = Schema.Struct({113const AuthorizationCodeSessionPayload = Schema.Struct({
@@ -344,9 +345,20 @@ export const makeOAuth2Service = ( · 231 unmodified lines
344 )345 )
345 : null;346 : null;
346 347
348 // Dynamic registration is only viable when the AS advertises a
349 // registration_endpoint AND a token_endpoint_auth_method we can use
350 // (`none`, `client_secret_post`, or `client_secret_basic`). If the AS
351 // doesn't list any methods we assume `none` is acceptable per OAuth
352 // 2.1 §2.4 (server's choice).
353 const advertisedAuthMethods =
354 authServer?.metadata.token_endpoint_auth_methods_supported ?? [];
355 const hasNegotiableAuthMethod =
356 advertisedAuthMethods.length === 0 ||
357 advertisedAuthMethods.some(
358 (m) => m === "none" || m === "client_secret_post" || m === "client_secret_basic",
359 );
347 const supportsDynamicRegistration = !!(360 const supportsDynamicRegistration = !!(
348 authServer?.metadata.registration_endpoint &&361 authServer?.metadata.registration_endpoint && hasNegotiableAuthMethod
349 (authServer.metadata.token_endpoint_auth_methods_supported ?? []).includes("none")
350 );362 );
351 363
352 // Bearer challenge probe — POST the endpoint unauth, look for364 // Bearer challenge probe — POST the endpoint unauth, look for
@@ -421,10 +433,12 @@ export const makeOAuth2Service = ( · 68 unmodified lines
421 resourceQueryParams: input.queryParams,433 resourceQueryParams: input.queryParams,
422 },434 },
423 ).pipe(435 ).pipe(
424 Effect.catchTag("OAuthDiscoveryError", ({ message }) =>436 Effect.catchTag("OAuthDiscoveryError", ({ message, error, errorDescription }) =>
425 Effect.fail(437 Effect.fail(
426 new OAuthStartError({438 new OAuthStartError({
427 message: `Dynamic authorization setup failed: ${message}`,439 message: `Dynamic authorization setup failed: ${message}`,
440 error,
441 errorDescription,
428 }),442 }),
429 ),443 ),
430 ),444 ),
@@ -444,9 +458,10 @@ export const makeOAuth2Service = ( · 13 unmodified lines
444 authorizationUrl: started.state.authorizationServerMetadata.authorization_endpoint,458 authorizationUrl: started.state.authorizationServerMetadata.authorization_endpoint,
445 clientId: started.state.clientInformation.client_id,459 clientId: started.state.clientInformation.client_id,
446 redirectUrl: input.redirectUrl,460 redirectUrl: input.redirectUrl,
447 scopes: strategy.scopes ?? started.state.authorizationServerMetadata.scopes_supported ?? [],461 scopes: started.state.scopes,
448 state: sessionId,462 state: sessionId,
449 codeChallenge,463 codeChallenge,
464 resource: started.state.resource,
450 });465 });
451 466
452 const payload: OAuthSessionPayload = {467 const payload: OAuthSessionPayload = {
@@ -466,9 +481,8 @@ export const makeOAuth2Service = ( · 13 unmodified lines
466 resourceMetadataUrl: started.state.resourceMetadataUrl,481 resourceMetadataUrl: started.state.resourceMetadataUrl,
467 resourceMetadata:482 resourceMetadata:
468 (started.state.resourceMetadata as Record<string, unknown> | null) ?? null,483 (started.state.resourceMetadata as Record<string, unknown> | null) ?? null,
469 scopes: [484 scopes: [...started.state.scopes],
470 ...(strategy.scopes ?? started.state.authorizationServerMetadata.scopes_supported ?? []),485 resource: started.state.resource,
471 ],
472 };486 };
473 487
474 yield* writeSession({488 yield* writeSession({
@@ -811,6 +825,7 @@ export const makeOAuth2Service = ( · 336 unmodified lines
811 : "body",825 : "body",
812 scopes: [...payload.scopes],826 scopes: [...payload.scopes],
813 scope: exchangeResult.tokens.scope ?? null,827 scope: exchangeResult.tokens.scope ?? null,
828 resource: payload.resource,
814 }829 }
815 : {830 : {
816 kind: "authorization-code",831 kind: "authorization-code",
@@ -922,6 +937,7 @@ export const makeOAuth2Service = ( · 105 unmodified lines
922 code,937 code,
923 idTokenSigningAlgValuesSupported: md.id_token_signing_alg_values_supported,938 idTokenSigningAlgValuesSupported: md.id_token_signing_alg_values_supported,
924 clientAuth: ci.token_endpoint_auth_method === "client_secret_basic" ? "basic" : "body",939 clientAuth: ci.token_endpoint_auth_method === "client_secret_basic" ? "basic" : "body",
940 resource: payload.resource ?? undefined,
925 }).pipe(941 }).pipe(
926 Effect.mapError(942 Effect.mapError(
927 ({ message, error }: OAuth2Error) =>943 ({ message, error }: OAuth2Error) =>
@@ -1198,6 +1214,10 @@ export const makeOAuth2Service = ( · 270 unmodified lines
1198 clientAuth: state.clientAuth,1214 clientAuth: state.clientAuth,
1199 idTokenSigningAlgValuesSupported:1215 idTokenSigningAlgValuesSupported:
1200 state.kind === "dynamic-dcr" ? state.idTokenSigningAlgValuesSupported : undefined,1216 state.kind === "dynamic-dcr" ? state.idTokenSigningAlgValuesSupported : undefined,
1217 resource:
1218 state.kind === "dynamic-dcr" || state.kind === "authorization-code"
1219 ? (state.resource ?? undefined)
1220 : undefined,
1201 })1221 })
1202 ).pipe(1222 ).pipe(
1203 Effect.mapError(1223 Effect.mapError(
packages/core/sdk/src/oauth.ts+9−0
@@ -127,6 +127,9 @@ export const OAuthProviderState = Schema.Union([ · 126 unmodified lines
127 scopes: Schema.Array(Schema.String).pipe(Schema.withDecodingDefaultType(Effect.succeed([]))),127 scopes: Schema.Array(Schema.String).pipe(Schema.withDecodingDefaultType(Effect.succeed([]))),
128 scopeSeparator: Schema.optional(Schema.String),128 scopeSeparator: Schema.optional(Schema.String),
129 scope: Schema.NullOr(Schema.String),129 scope: Schema.NullOr(Schema.String),
130 /** RFC 8707 canonical resource URL. Replayed on refresh so the new
131 * access token's audience stays bound to the same resource. */
132 resource: Schema.optional(Schema.NullOr(Schema.String)),
130 }),133 }),
131 Schema.Struct({134 Schema.Struct({
132 kind: Schema.Literal("authorization-code"),135 kind: Schema.Literal("authorization-code"),
@@ -138,6 +141,7 @@ export const OAuthProviderState = Schema.Union([ · 5 unmodified lines
138 scopes: Schema.Array(Schema.String).pipe(Schema.withDecodingDefaultType(Effect.succeed([]))),141 scopes: Schema.Array(Schema.String).pipe(Schema.withDecodingDefaultType(Effect.succeed([]))),
139 scopeSeparator: Schema.optional(Schema.String),142 scopeSeparator: Schema.optional(Schema.String),
140 scope: Schema.NullOr(Schema.String),143 scope: Schema.NullOr(Schema.String),
144 resource: Schema.optional(Schema.NullOr(Schema.String)),
141 }),145 }),
142 Schema.Struct({146 Schema.Struct({
143 kind: Schema.Literal("client-credentials"),147 kind: Schema.Literal("client-credentials"),
@@ -268,6 +272,11 @@ export class OAuthProbeError extends Schema.TaggedErrorClass<OAuthProbeError>()( · 124 unmodified lines
268 272
269export class OAuthStartError extends Schema.TaggedErrorClass<OAuthStartError>()("OAuthStartError", {273export class OAuthStartError extends Schema.TaggedErrorClass<OAuthStartError>()("OAuthStartError", {
270 message: Schema.String,274 message: Schema.String,
275 /** RFC 6749 §5.2 / RFC 7591 §3.2.2 error code propagated up from the
276 * authorization server (e.g. `invalid_client_metadata`). UI surfaces
277 * it as the structured "AS rejected the registration" reason. */
278 error: Schema.optional(Schema.String),
279 errorDescription: Schema.optional(Schema.String),
271}) {280}) {
272 static annotations = { httpApiStatus: 400 };281 static annotations = { httpApiStatus: 400 };
273}282}