4141from .utils import get_timezone
4242
4343
44+ def validate_resource_as_url_prefix (request_uri , audiences ):
45+ """
46+ Default resource validator using URL prefix matching (RFC 8707).
47+
48+ Validates that the request URI matches one of the token's audience claims
49+ using prefix matching. The audience URI acts as a base URI that the request
50+ must start with.
51+
52+ Examples:
53+ - Token audience: "https://api.example.com/foo"
54+ - Matches: "https://api.example.com/foo"
55+ - Matches: "https://api.example.com/foo/"
56+ - Matches: "https://api.example.com/foo/bar"
57+ - Rejects: "https://other.example.com/foo/bar"
58+ - Rejects: "https://api.example.com/bar"
59+ - Rejects: "https://api.example.com/food-blog"
60+
61+ :param request_uri: String URI of the current request (without query string)
62+ :param audiences: List of audience URI strings from token
63+ :return: True if token is valid for this request, False otherwise
64+ """
65+ # No audiences = unrestricted token (backward compatibility)
66+ if not audiences :
67+ return True
68+
69+ request_normalized = request_uri .rstrip ("/" ) + "/"
70+
71+ # Check if request URI starts with any of the audience URIs
72+ for audience in audiences :
73+ audience_normalized = audience .rstrip ("/" ) + "/"
74+
75+ if request_normalized .startswith (audience_normalized ):
76+ return True
77+
78+ return False
79+
80+
4481log = logging .getLogger ("oauth2_provider" )
4582
4683GRANT_TYPE_MAPPING = {
@@ -481,6 +518,14 @@ def validate_bearer_token(self, token, scopes, request):
481518 )
482519
483520 if access_token and access_token .is_valid (scopes ):
521+ # RFC 8707: Validate token audience against request resource
522+ # Use request.uri which is the full URI from the oauthlib Request object
523+ request_uri = request .uri .split ("?" )[0 ]
524+ if not access_token .allows_audience (request_uri ):
525+ # Token is valid but not authorized for this resource
526+ self ._set_oauth2_error_on_request (request , access_token , scopes )
527+ return False
528+
484529 request .client = access_token .application
485530 request .user = access_token .user
486531 request .scopes = list (access_token .scopes )
@@ -605,6 +650,55 @@ def save_bearer_token(self, token, request, *args, **kwargs):
605650 with transaction .atomic (using = router .db_for_write (AccessToken )):
606651 return self ._save_bearer_token (token , request , * args , ** kwargs )
607652
653+ def _check_and_set_request_resource (self , request ):
654+ """
655+ Handle 'resource' parameter from token requests (RFC 8707).
656+ Normalizes request.resource to a JSON-encoded array of URIs.
657+
658+ request.resource will be set to one of:
659+ - Empty string "" (no resources)
660+ - JSON-encoded array '["https://api.example.com"]' or '["https://a.com", "https://b.com"]'
661+ """
662+ resource = getattr (request , "resource" , None ) or ""
663+ if isinstance (resource , list ):
664+ request .resource = json .dumps (resource )
665+ elif resource and resource .strip ():
666+ # It's a non-empty string - check if already JSON-encoded
667+ try :
668+ parsed = json .loads (resource )
669+ except (json .JSONDecodeError , TypeError ):
670+ # Not JSON, it's a single URI string from token endpoint POST
671+ request .resource = json .dumps ([resource ])
672+ else :
673+ assert isinstance (parsed , list )
674+ request .resource = resource
675+ else :
676+ request .resource = ""
677+
678+ if request .grant_type == "authorization_code" :
679+ # Handle grant resource narrowing
680+ grant = Grant .objects .filter (code = request .code , application = request .client ).first ()
681+ grant_resource = grant .resource if (grant and grant .resource ) else ""
682+
683+ if request .resource and grant_resource :
684+ # Token request is narrowing the resource scope
685+ # Validate that requested resources are a subset of granted resources
686+ requested_list = json .loads (request .resource )
687+ granted_list = json .loads (grant_resource )
688+
689+ for res in requested_list :
690+ if res not in granted_list :
691+ raise errors .CustomOAuth2Error (
692+ error = "invalid_target" ,
693+ description = (
694+ f"Requested resource '{ res } ' was not included in the "
695+ "original authorization grant"
696+ ),
697+ request = request ,
698+ )
699+ elif grant_resource :
700+ request .resource = grant_resource
701+
608702 def _save_bearer_token (self , token , request , * args , ** kwargs ):
609703 """
610704 Save access and refresh token.
@@ -617,80 +711,7 @@ def _save_bearer_token(self, token, request, *args, **kwargs):
617711 if "scope" not in token :
618712 raise FatalClientError ("Failed to renew access token: missing scope" )
619713
620- # RFC 8707: Extract resource parameter from request
621- # For authorization_code grant, resource comes from the grant (already JSON-encoded)
622- # but can be narrowed by the token request
623- # For other grants, it comes from the request directly and needs encoding
624- if request .grant_type == "authorization_code" :
625- # Get resource from the grant that was validated
626- grant = Grant .objects .filter (code = request .code , application = request .client ).first ()
627- grant_resource = grant .resource if (grant and grant .resource ) else ""
628-
629- # Check if token request specifies a subset of resources
630- requested_resource = getattr (request , "resource" , None )
631- if requested_resource :
632- # RFC 8707: Token request is narrowing the resource scope
633- # Validate that requested resources are a subset of granted resources
634- if isinstance (requested_resource , str ):
635- requested_list = [requested_resource ]
636- else :
637- requested_list = requested_resource
638-
639- # Parse granted resources
640- if grant_resource :
641- try :
642- granted_list = json .loads (grant_resource )
643- except (json .JSONDecodeError , TypeError ):
644- granted_list = []
645- else :
646- granted_list = []
647-
648- # Validate that all requested resources were granted
649- if granted_list : # Only validate if resources were originally granted
650- for res in requested_list :
651- if res not in granted_list :
652- # RFC 8707: Use invalid_target error per spec
653- raise errors .CustomOAuth2Error (
654- error = "invalid_target" ,
655- description = (
656- f"Requested resource '{ res } ' was not included in the "
657- "original authorization grant"
658- ),
659- request = request ,
660- )
661-
662- request .resource = json .dumps (requested_list )
663- elif grant_resource :
664- # Use all resources from the grant
665- request .resource = grant_resource
666- else :
667- request .resource = ""
668- else :
669- # For other grant types (client_credentials, password, implicit, etc.)
670- # Extract resource from request and JSON-encode it if needed
671- resource = getattr (request , "resource" , None )
672- if resource :
673- # Check if already JSON-encoded (from authorization endpoint)
674- # vs raw from token endpoint
675- if isinstance (resource , str ):
676- # Could be either a single URI or already JSON-encoded
677- try :
678- # Try to parse as JSON
679- parsed = json .loads (resource )
680- if isinstance (parsed , list ):
681- # Already JSON-encoded, use as-is
682- request .resource = resource
683- else :
684- # Single URI, needs encoding
685- request .resource = json .dumps ([resource ])
686- except (json .JSONDecodeError , TypeError ):
687- # Not JSON, it's a single URI
688- request .resource = json .dumps ([resource ])
689- else :
690- # It's a list, encode it
691- request .resource = json .dumps (resource )
692- else :
693- request .resource = ""
714+ self ._check_and_set_request_resource (request )
694715
695716 # expires_in is passed to Server on initialization
696717 # custom server class can have logic to override this
0 commit comments