From e26fc1e25cb4411a23a8b0832ecfe1a6b2067e39 Mon Sep 17 00:00:00 2001 From: martin el cheikh Date: Fri, 30 Oct 2026 23:31:57 +0000 Subject: [PATCH] feat(oauthserver): bind access tokens to RFC 8707 resource indicators (#2610) The OAuth 2.1 server accepted and stored the `resource` parameter but never used it: every access token had `aud: "authenticated"`, so MCP servers that validate the audience rejected them. - Store the authorized resource on the session (new `sessions.resource` column) and add it to the access token `aud` claim on code exchange and every refresh. The user's audience stays first, so `aud[0]` and Data API compatibility are unchanged. - Read `resource` from form-encoded token requests. Before, only JSON bodies were checked against the authorized resource. - Reject a refresh that asks for a resource the session was not granted. - Use the RFC 8707 `invalid_target` error code for invalid, mismatched or multiple resource values, at both endpoints. - Enforce the 2048 character limit at the authorize endpoint instead of failing on the database constraint. - Return `resource` in the authorization details so consent screens can show it, and document the parameter in openapi.yaml. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0187GfsUpzmeh8pDYi3eBwEq --- internal/api/oauthserver/authorize.go | 31 +- internal/api/oauthserver/handlers.go | 13 +- .../oauthserver/resource_indicator_test.go | 304 ++++++++++++++++++ internal/models/refresh_token.go | 5 + internal/models/sessions.go | 3 +- internal/tokens/service.go | 17 +- ...1030120000_add_resource_to_sessions.up.sql | 15 + openapi.yaml | 16 + 8 files changed, 395 insertions(+), 9 deletions(-) create mode 100644 internal/api/oauthserver/resource_indicator_test.go create mode 100644 migrations/20261030120000_add_resource_to_sessions.up.sql diff --git a/internal/api/oauthserver/authorize.go b/internal/api/oauthserver/authorize.go index 2addacfdaa..92333800a8 100644 --- a/internal/api/oauthserver/authorize.go +++ b/internal/api/oauthserver/authorize.go @@ -40,6 +40,7 @@ type AuthorizationDetailsResponse struct { Client ClientDetailsResponse `json:"client,omitempty"` User UserDetailsResponse `json:"user,omitempty"` Scope string `json:"scope,omitempty"` + Resource string `json:"resource,omitempty"` } // ClientDetailsResponse represents client details in authorization response @@ -78,8 +79,14 @@ const ( oAuth2ErrorInvalidRequest = "invalid_request" oAuth2ErrorServerError = "server_error" oAuth2ErrorAccessDenied = "access_denied" + + // RFC 8707 error code for invalid resource indicators + oAuth2ErrorInvalidTarget = "invalid_target" ) +// maxResourceLength matches the length limit of the resource columns +const maxResourceLength = 2048 + // OAuthServerAuthorize handles GET /oauth/authorize func (s *Server) OAuthServerAuthorize(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() @@ -135,6 +142,18 @@ func (s *Server) OAuthServerAuthorize(w http.ResponseWriter, r *http.Request) er return nil } + // Resource indicator errors use the invalid_target code (RFC 8707) + if len(query["resource"]) > 1 { + err = errors.New("only one resource parameter is supported") + } else { + err = s.validateResourceParam(params.Resource) + } + if err != nil { + errorRedirectURL := s.buildErrorRedirectURL(params.RedirectURI, oAuth2ErrorInvalidTarget, err.Error(), params.State) + http.Redirect(w, r, errorRedirectURL, http.StatusFound) + return nil + } + // Store authorization request in database (without user initially) authorization := models.NewOAuthServerAuthorization(models.NewOAuthServerAuthorizationParams{ ClientID: client.ID, @@ -291,7 +310,8 @@ func (s *Server) OAuthServerGetAuthorization(w http.ResponseWriter, r *http.Requ ID: user.ID.String(), Email: user.Email.String(), }, - Scope: authorization.Scope, + Scope: authorization.Scope, + Resource: utilities.StringValue(authorization.Resource), } return shared.SendJSON(w, http.StatusOK, response) @@ -462,11 +482,6 @@ func (s *Server) validateRemainingAuthorizeParams(params *AuthorizeParams) error return err } - // Resource parameter validation (per RFC 8707) - if err := s.validateResourceParam(params.Resource); err != nil { - return err - } - // PKCE validation if err := s.validatePKCEParams(params.CodeChallengeMethod, params.CodeChallenge); err != nil { return err @@ -502,6 +517,10 @@ func (s *Server) validateResourceParam(resource string) error { return nil } + if len(resource) > maxResourceLength { + return fmt.Errorf("resource must be at most %d characters", maxResourceLength) + } + // Parse URL to validate it's an absolute URI parsedURL, err := url.Parse(resource) if err != nil { diff --git a/internal/api/oauthserver/handlers.go b/internal/api/oauthserver/handlers.go index bd93f7801f..9cc509a3e8 100644 --- a/internal/api/oauthserver/handlers.go +++ b/internal/api/oauthserver/handlers.go @@ -288,6 +288,13 @@ func (s *Server) OAuthToken(w http.ResponseWriter, r *http.Request) error { params.ClientID = r.FormValue("client_id") params.ClientSecret = r.FormValue("client_secret") params.CodeVerifier = r.FormValue("code_verifier") + + // RFC 8707 allows several resource parameters, but each grant is + // bound to a single resource + if len(r.Form["resource"]) > 1 { + return apierrors.NewOAuthError("invalid_target", "Only one resource parameter is supported") + } + params.Resource = r.FormValue("resource") } // Validate grant_type @@ -355,7 +362,7 @@ func (s *Server) handleAuthorizationCodeGrant(ctx context.Context, w http.Respon // Validate that (if exists) the resource parameter matches the authorization code resource if params.Resource != "" && params.Resource != utilities.StringValue(authorization.Resource) { - return apierrors.NewOAuthError("invalid_grant", "Authorization code resource does not match the resource parameter") + return apierrors.NewOAuthError("invalid_target", "Authorization code resource does not match the resource parameter") } // Validate redirect_uri if provided - must match the one used in authorization @@ -395,6 +402,9 @@ func (s *Server) handleAuthorizationCodeGrant(ctx context.Context, w http.Respon scopes := authorization.Scope grantParams.Scopes = &scopes + // Store the granted resource in session so access tokens are bound to it + grantParams.Resource = authorization.Resource + err = db.Transaction(func(tx *storage.Connection) error { if _, terr := models.FindOAuthServerAuthorizationByIDForUpdate(tx, authorization.AuthorizationID); terr != nil { if models.IsNotFoundError(terr) { @@ -506,6 +516,7 @@ func (s *Server) handleRefreshTokenGrant(ctx context.Context, w http.ResponseWri tokenResponse, err := tokenService.RefreshTokenGrant(ctx, db, r, w.Header(), tokens.RefreshTokenGrantParams{ RefreshToken: params.RefreshToken, ClientID: clientID, + Resource: params.Resource, }) if err != nil { return err diff --git a/internal/api/oauthserver/resource_indicator_test.go b/internal/api/oauthserver/resource_indicator_test.go new file mode 100644 index 0000000000..9080a43777 --- /dev/null +++ b/internal/api/oauthserver/resource_indicator_test.go @@ -0,0 +1,304 @@ +package oauthserver + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "time" + + "github.com/gofrs/uuid" + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/supabase/auth/internal/api/apierrors" + "github.com/supabase/auth/internal/api/shared" + "github.com/supabase/auth/internal/models" +) + +const testResource = "https://mcp.example.com/mcp" + +// ---------- token endpoint (RFC 8707 section 2.2) ---------- + +func (ts *OAuthClientTestSuite) mintApprovedCodeWithResource(clientID, userID uuid.UUID, resource string) string { + auth := models.NewOAuthServerAuthorization(models.NewOAuthServerAuthorizationParams{ + ClientID: clientID, + RedirectURI: "https://example.com/callback", + Scope: "profile", + Resource: resource, + TTL: time.Hour, + }) + require.NoError(ts.T(), models.CreateOAuthServerAuthorization(ts.DB, auth)) + require.NoError(ts.T(), auth.SetUser(ts.DB, userID)) + require.NoError(ts.T(), auth.Approve(ts.DB)) + return *auth.AuthorizationCode +} + +// postTokenForm calls the token endpoint with a form-encoded body, as OAuth clients do +func (ts *OAuthClientTestSuite) postTokenForm(client *models.OAuthServerClient, form url.Values) (map[string]interface{}, error) { + req := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req = req.WithContext(shared.WithOAuthServerClient(req.Context(), client)) + w := httptest.NewRecorder() + + if err := ts.Server.OAuthToken(w, req); err != nil { + return nil, err + } + require.Equal(ts.T(), http.StatusOK, w.Code, w.Body.String()) + + var resp map[string]interface{} + require.NoError(ts.T(), json.Unmarshal(w.Body.Bytes(), &resp)) + return resp, nil +} + +func (ts *OAuthClientTestSuite) accessTokenClaims(resp map[string]interface{}) jwt.MapClaims { + accessToken, ok := resp["access_token"].(string) + require.True(ts.T(), ok, "response is missing access_token") + + claims := jwt.MapClaims{} + _, _, err := jwt.NewParser().ParseUnverified(accessToken, claims) + require.NoError(ts.T(), err) + return claims +} + +func (ts *OAuthClientTestSuite) accessTokenAudience(resp map[string]interface{}) []string { + aud, err := ts.accessTokenClaims(resp).GetAudience() + require.NoError(ts.T(), err) + return aud +} + +func (ts *OAuthClientTestSuite) assertOAuthError(err error, code string) { + ts.T().Helper() + oauthErr, ok := err.(*apierrors.OAuthError) + require.True(ts.T(), ok, "expected *apierrors.OAuthError, got %T (%v)", err, err) + assert.Equal(ts.T(), code, oauthErr.Err) +} + +func (ts *OAuthClientTestSuite) TestResourceIndicator_CodeGrantBindsAudience() { + client, _ := ts.createTestOAuthClient() + user := ts.createTestUser("resource-code@example.com") + code := ts.mintApprovedCodeWithResource(client.ID, user.ID, testResource) + + resp, err := ts.postTokenForm(client, url.Values{ + "grant_type": {GrantTypeAuthorizationCode}, + "code": {code}, + "resource": {testResource}, + }) + require.NoError(ts.T(), err) + + // The user's audience stays first so the API keeps reading it from aud[0] + assert.Equal(ts.T(), []string{"authenticated", testResource}, ts.accessTokenAudience(resp)) + + sessionID, ok := ts.accessTokenClaims(resp)["session_id"].(string) + require.True(ts.T(), ok) + session, err := models.FindSessionByID(ts.DB, uuid.Must(uuid.FromString(sessionID)), false) + require.NoError(ts.T(), err) + require.NotNil(ts.T(), session.Resource) + assert.Equal(ts.T(), testResource, *session.Resource) +} + +func (ts *OAuthClientTestSuite) TestResourceIndicator_CodeGrantWithoutResourceParamUsesAuthorizedResource() { + client, _ := ts.createTestOAuthClient() + user := ts.createTestUser("resource-code-implicit@example.com") + code := ts.mintApprovedCodeWithResource(client.ID, user.ID, testResource) + + resp, err := ts.postTokenForm(client, url.Values{ + "grant_type": {GrantTypeAuthorizationCode}, + "code": {code}, + }) + require.NoError(ts.T(), err) + assert.Equal(ts.T(), []string{"authenticated", testResource}, ts.accessTokenAudience(resp)) +} + +func (ts *OAuthClientTestSuite) TestResourceIndicator_NoResourceKeepsDefaultAudience() { + client, _ := ts.createTestOAuthClient() + user := ts.createTestUser("resource-none@example.com") + code := ts.mintApprovedCodeWithResource(client.ID, user.ID, "") + + resp, err := ts.postTokenForm(client, url.Values{ + "grant_type": {GrantTypeAuthorizationCode}, + "code": {code}, + }) + require.NoError(ts.T(), err) + assert.Equal(ts.T(), []string{"authenticated"}, ts.accessTokenAudience(resp)) +} + +func (ts *OAuthClientTestSuite) TestResourceIndicator_CodeGrantRejectsMismatchedResource() { + client, _ := ts.createTestOAuthClient() + user := ts.createTestUser("resource-mismatch@example.com") + code := ts.mintApprovedCodeWithResource(client.ID, user.ID, testResource) + + // Form-encoded requests must be checked too, not only JSON ones + _, err := ts.postTokenForm(client, url.Values{ + "grant_type": {GrantTypeAuthorizationCode}, + "code": {code}, + "resource": {"https://other.example.com/mcp"}, + }) + ts.assertOAuthError(err, "invalid_target") + + // A rejected request must not consume the code + _, err = models.FindOAuthServerAuthorizationByCode(ts.DB, code) + require.NoError(ts.T(), err) +} + +func (ts *OAuthClientTestSuite) TestResourceIndicator_CodeGrantRejectsResourceNotAuthorized() { + client, _ := ts.createTestOAuthClient() + user := ts.createTestUser("resource-not-authorized@example.com") + code := ts.mintApprovedCodeWithResource(client.ID, user.ID, "") + + _, err := ts.postTokenForm(client, url.Values{ + "grant_type": {GrantTypeAuthorizationCode}, + "code": {code}, + "resource": {testResource}, + }) + ts.assertOAuthError(err, "invalid_target") +} + +func (ts *OAuthClientTestSuite) TestResourceIndicator_TokenRejectsMultipleResources() { + client, _ := ts.createTestOAuthClient() + user := ts.createTestUser("resource-multiple@example.com") + code := ts.mintApprovedCodeWithResource(client.ID, user.ID, testResource) + + _, err := ts.postTokenForm(client, url.Values{ + "grant_type": {GrantTypeAuthorizationCode}, + "code": {code}, + "resource": {testResource, "https://other.example.com/mcp"}, + }) + ts.assertOAuthError(err, "invalid_target") +} + +func (ts *OAuthClientTestSuite) TestResourceIndicator_RefreshKeepsAudience() { + client, _ := ts.createTestOAuthClient() + user := ts.createTestUser("resource-refresh@example.com") + code := ts.mintApprovedCodeWithResource(client.ID, user.ID, testResource) + + resp, err := ts.postTokenForm(client, url.Values{ + "grant_type": {GrantTypeAuthorizationCode}, + "code": {code}, + }) + require.NoError(ts.T(), err) + + // Refresh without a resource parameter + resp, err = ts.postTokenForm(client, url.Values{ + "grant_type": {GrantTypeRefreshToken}, + "refresh_token": {resp["refresh_token"].(string)}, + }) + require.NoError(ts.T(), err) + assert.Equal(ts.T(), []string{"authenticated", testResource}, ts.accessTokenAudience(resp)) + + // Refresh asking again for the granted resource + resp, err = ts.postTokenForm(client, url.Values{ + "grant_type": {GrantTypeRefreshToken}, + "refresh_token": {resp["refresh_token"].(string)}, + "resource": {testResource}, + }) + require.NoError(ts.T(), err) + assert.Equal(ts.T(), []string{"authenticated", testResource}, ts.accessTokenAudience(resp)) +} + +func (ts *OAuthClientTestSuite) TestResourceIndicator_RefreshRejectsResourceNotGranted() { + cases := []struct { + name string + grantedResource string + }{ + {name: "different resource", grantedResource: testResource}, + {name: "session without resource", grantedResource: ""}, + } + + for i, c := range cases { + ts.Run(c.name, func() { + client, _ := ts.createTestOAuthClient() + user := ts.createTestUser(fmt.Sprintf("resource-refresh-reject-%d@example.com", i)) + code := ts.mintApprovedCodeWithResource(client.ID, user.ID, c.grantedResource) + + resp, err := ts.postTokenForm(client, url.Values{ + "grant_type": {GrantTypeAuthorizationCode}, + "code": {code}, + }) + require.NoError(ts.T(), err) + + _, err = ts.postTokenForm(client, url.Values{ + "grant_type": {GrantTypeRefreshToken}, + "refresh_token": {resp["refresh_token"].(string)}, + "resource": {"https://other.example.com/mcp"}, + }) + ts.assertOAuthError(err, "invalid_target") + }) + } +} + +// ---------- authorization endpoint (RFC 8707 section 2) ---------- + +func (ts *OAuthAuthorizeTestSuite) authorizeQuery(clientID uuid.UUID) url.Values { + return url.Values{ + "client_id": {clientID.String()}, + "redirect_uri": {"https://example.com/callback"}, + "scope": {"openid"}, + "state": {"xyz"}, + "code_challenge": {"abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"}, + "code_challenge_method": {"S256"}, + } +} + +func (ts *OAuthAuthorizeTestSuite) authorize(q url.Values) *url.URL { + req := httptest.NewRequest(http.MethodGet, "/oauth/authorize?"+q.Encode(), nil) + w := httptest.NewRecorder() + require.NoError(ts.T(), ts.Server.OAuthServerAuthorize(w, req)) + require.Equal(ts.T(), http.StatusFound, w.Code, w.Body.String()) + + loc, err := url.Parse(w.Header().Get("Location")) + require.NoError(ts.T(), err) + return loc +} + +func (ts *OAuthAuthorizeTestSuite) TestAuthorize_ResourceIsStoredAndShownInDetails() { + user := ts.createUser("authorize-resource@example.com") + client := ts.createClient() + + q := ts.authorizeQuery(client.ID) + q.Set("resource", testResource) + loc := ts.authorize(q) + + authID := loc.Query().Get("authorization_id") + require.NotEmpty(ts.T(), authID, "unexpected error=%q", loc.Query().Get("error")) + auth := ts.reload(authID) + require.NotNil(ts.T(), auth.Resource) + assert.Equal(ts.T(), testResource, *auth.Resource) + + // The consent screen can show which resource the client asked for + w := httptest.NewRecorder() + require.NoError(ts.T(), ts.Server.OAuthServerGetAuthorization(w, ts.newRequest(http.MethodGet, authID, user, nil))) + var resp AuthorizationDetailsResponse + require.NoError(ts.T(), json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(ts.T(), testResource, resp.Resource) +} + +func (ts *OAuthAuthorizeTestSuite) TestAuthorize_InvalidResourceRedirectsWithInvalidTarget() { + client := ts.createClient() + + cases := []struct { + name string + resources []string + }{ + {name: "relative URI", resources: []string{"/mcp"}}, + {name: "fragment", resources: []string{testResource + "#section"}}, + {name: "query", resources: []string{testResource + "?tenant=a"}}, + {name: "too long", resources: []string{"https://mcp.example.com/" + strings.Repeat("a", maxResourceLength)}}, + {name: "multiple resources", resources: []string{testResource, "https://other.example.com/mcp"}}, + } + + for _, c := range cases { + ts.Run(c.name, func() { + q := ts.authorizeQuery(client.ID) + q["resource"] = c.resources + loc := ts.authorize(q) + + assert.Equal(ts.T(), "https://example.com/callback", loc.Scheme+"://"+loc.Host+loc.Path) + assert.Equal(ts.T(), "invalid_target", loc.Query().Get("error")) + assert.Equal(ts.T(), "xyz", loc.Query().Get("state")) + assert.Empty(ts.T(), loc.Query().Get("authorization_id")) + }) + } +} diff --git a/internal/models/refresh_token.go b/internal/models/refresh_token.go index 8b4cc2d8e1..e89cbbc42f 100644 --- a/internal/models/refresh_token.go +++ b/internal/models/refresh_token.go @@ -48,6 +48,7 @@ type GrantParams struct { OAuthClientID *uuid.UUID Scopes *string + Resource *string UserAgent string IP string @@ -146,6 +147,10 @@ func (s *Session) ApplyGrantParams(params *GrantParams) { if params.Scopes != nil && *params.Scopes != "" { s.Scopes = params.Scopes } + + if params.Resource != nil && *params.Resource != "" { + s.Resource = params.Resource + } } func (s *Session) SetupRefreshTokenData(dbEncryption conf.DatabaseEncryptionConfiguration) error { diff --git a/internal/models/sessions.go b/internal/models/sessions.go index 92580d365a..b7d4eaea07 100644 --- a/internal/models/sessions.go +++ b/internal/models/sessions.go @@ -94,7 +94,8 @@ type Session struct { Tag *string `json:"tag" db:"tag"` OAuthClientID *uuid.UUID `json:"oauth_client_id" db:"oauth_client_id"` - Scopes *string `json:"scopes,omitempty" db:"scopes"` // OAuth scopes granted for this session + Scopes *string `json:"scopes,omitempty" db:"scopes"` // OAuth scopes granted for this session + Resource *string `json:"resource,omitempty" db:"resource"` // RFC 8707 resource indicator granted for this session RefreshTokenHmacKey *string `json:"-" db:"refresh_token_hmac_key"` RefreshTokenCounter *int64 `json:"-" db:"refresh_token_counter"` diff --git a/internal/tokens/service.go b/internal/tokens/service.go index 6cf445cf11..4a68ae16af 100644 --- a/internal/tokens/service.go +++ b/internal/tokens/service.go @@ -142,6 +142,7 @@ type GenerateIDTokenParams struct { type RefreshTokenGrantParams struct { RefreshToken string ClientID *uuid.UUID // OAuth2 server client ID if applicable + Resource string // RFC 8707 resource indicator requested on refresh, if any } // AsRedirectURL encodes the AccessTokenResponse as a redirect URL that @@ -313,6 +314,12 @@ func (s *Service) RefreshTokenGrant(ctx context.Context, db *storage.Connection, sessionClientID = nil } + // Per RFC 8707 a refresh request can only ask for a resource + // that was granted for this session + if params.Resource != "" && params.Resource != utilities.StringValue(session.Resource) { + return apierrors.NewOAuthError("invalid_target", "Requested resource was not granted for this session") + } + if config.Sessions.SinglePerUser { sessions, terr := models.FindAllSessionsForUser(tx, user.ID, true /* forUpdate */) if models.IsNotFoundError(terr) { @@ -710,10 +717,18 @@ func (s *Service) GenerateAccessToken(r *http.Request, tx *storage.Connection, p scopes = *session.Scopes } + // Bind the token to the RFC 8707 resource granted for this session. The + // user's audience stays first since the API reads the request audience + // from aud[0], and it keeps the token usable with the Data API. + audience := jwt.ClaimStrings{params.User.Aud} + if resource := utilities.StringValue(session.Resource); resource != "" { + audience = append(audience, resource) + } + claims := &v0hooks.AccessTokenClaims{ RegisteredClaims: jwt.RegisteredClaims{ Subject: params.User.ID.String(), - Audience: jwt.ClaimStrings{params.User.Aud}, + Audience: audience, IssuedAt: jwt.NewNumericDate(issuedAt), ExpiresAt: jwt.NewNumericDate(expiresAt), Issuer: config.JWT.Issuer, diff --git a/migrations/20261030120000_add_resource_to_sessions.up.sql b/migrations/20261030120000_add_resource_to_sessions.up.sql new file mode 100644 index 0000000000..11ece345ee --- /dev/null +++ b/migrations/20261030120000_add_resource_to_sessions.up.sql @@ -0,0 +1,15 @@ +-- Add resource column to sessions table to bind OAuth access tokens to the +-- RFC 8707 resource indicator granted at authorization time. +-- This is nullable to avoid issues with existing sessions. +/* auth_migration: 20261030120000 */ +alter table if exists {{ index .Options "Namespace" }}.sessions + add column if not exists resource text null; + +-- Same length limit as oauth_authorizations.resource +/* auth_migration: 20261030120000 */ +do $$ begin + alter table {{ index .Options "Namespace" }}.sessions + add constraint sessions_resource_length check (char_length(resource) <= 2048); +exception + when duplicate_object then null; +end $$; diff --git a/openapi.yaml b/openapi.yaml index 1f04ee575d..8fb9a7ee9b 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -3159,6 +3159,11 @@ paths: client_secret: type: string description: OAuth client secret (for confidential clients) + resource: + type: string + format: uri + description: > + RFC 8707 resource indicator. Optional; when sent it must match the resource granted in the authorization request, otherwise an `invalid_target` error is returned. responses: 200: description: Access token issued successfully @@ -3240,6 +3245,13 @@ paths: enum: - S256 description: PKCE code challenge method + - name: resource + in: query + schema: + type: string + format: uri + description: > + RFC 8707 resource indicator: the absolute URI (without fragment or query) of the resource server the token is for. When present, access tokens include it in the `aud` claim next to the user's audience. Invalid values redirect with an `invalid_target` error. responses: 302: description: Redirect to login or authorization page @@ -3305,6 +3317,10 @@ paths: format: email scope: type: string + resource: + type: string + format: uri + description: RFC 8707 resource indicator requested by the client, if any 404: description: Authorization not found content: