| 
2 | 2 | import logging  | 
3 | 3 | from typing import List  | 
4 | 4 | from typing import Optional  | 
 | 5 | +from typing import TypeVar  | 
5 | 6 | from typing import Union  | 
 | 7 | +from urllib.parse import ParseResult  | 
 | 8 | +from urllib.parse import SplitResult  | 
 | 9 | +from urllib.parse import parse_qs  | 
6 | 10 | from urllib.parse import unquote  | 
7 | 11 | from urllib.parse import urlencode  | 
8 | 12 | from urllib.parse import urlparse  | 
 | 
21 | 25 | from idpyoidc.message import Message  | 
22 | 26 | from idpyoidc.message import oauth2  | 
23 | 27 | from idpyoidc.message.oauth2 import AuthorizationRequest  | 
 | 28 | +from idpyoidc.message.oidc import APPLICATION_TYPE_NATIVE  | 
 | 29 | +from idpyoidc.message.oidc import APPLICATION_TYPE_WEB  | 
24 | 30 | from idpyoidc.message.oidc import AuthorizationResponse  | 
25 | 31 | from idpyoidc.message.oidc import verified_claim_name  | 
26 | 32 | from idpyoidc.server.authn_event import create_authn_event  | 
 | 
41 | 47 | from idpyoidc.time_util import utc_time_sans_frac  | 
42 | 48 | from idpyoidc.util import importer  | 
43 | 49 | from idpyoidc.util import rndstr  | 
44 |  | -from idpyoidc.util import split_uri  | 
 | 50 | + | 
 | 51 | + | 
 | 52 | +ParsedURI = TypeVar('ParsedURI', ParseResult, SplitResult)  | 
45 | 53 | 
 
  | 
46 | 54 | logger = logging.getLogger(__name__)  | 
47 | 55 | 
 
  | 
@@ -106,80 +114,115 @@ def verify_uri(  | 
106 | 114 |     :param context: An EndpointContext instance  | 
107 | 115 |     :param request: The authorization request  | 
108 | 116 |     :param uri_type: redirect_uri or post_logout_redirect_uri  | 
109 |  | -    :return: An error response if the redirect URI is faulty otherwise  | 
110 |  | -        None  | 
 | 117 | +    :return: Raise an exception response if the redirect URI is faulty otherwise None  | 
111 | 118 |     """  | 
112 |  | -    _cid = request.get("client_id", client_id)  | 
113 | 119 | 
 
  | 
114 |  | -    if not _cid:  | 
115 |  | -        logger.error("No client id found")  | 
 | 120 | +    client_id = request.get("client_id") or client_id  | 
 | 121 | +    if not client_id:  | 
 | 122 | +        logger.error("No client_id provided")  | 
116 | 123 |         raise UnknownClient("No client_id provided")  | 
117 | 124 | 
 
  | 
118 |  | -    _uri = request.get(uri_type)  | 
119 |  | -    if _uri is None:  | 
120 |  | -        raise ValueError(f"Wrong uri_type: {uri_type}")  | 
 | 125 | +    client_info = context.cdb.get(client_id)  | 
 | 126 | +    if not client_info:  | 
 | 127 | +        logger.error("No client info found")  | 
 | 128 | +        raise KeyError("No client info found")  | 
121 | 129 | 
 
  | 
122 |  | -    _redirect_uri = unquote(_uri)  | 
 | 130 | +    req_redirect_uri_quoted = request.get(uri_type)  | 
 | 131 | +    if req_redirect_uri_quoted is None:  | 
 | 132 | +        raise ValueError(f"Wrong uri_type: {uri_type}")  | 
123 | 133 | 
 
  | 
124 |  | -    part = urlparse(_redirect_uri)  | 
125 |  | -    if part.fragment:  | 
 | 134 | +    req_redirect_uri = unquote(req_redirect_uri_quoted)  | 
 | 135 | +    req_redirect_uri_obj = urlparse(req_redirect_uri)  | 
 | 136 | +    if req_redirect_uri_obj.fragment:  | 
126 | 137 |         raise URIError("Contains fragment")  | 
127 | 138 | 
 
  | 
128 |  | -    (_base, _query) = split_uri(_redirect_uri)  | 
 | 139 | +    # basic URL validation  | 
 | 140 | +    if not req_redirect_uri_obj.hostname:  | 
 | 141 | +        raise URIError("Invalid redirect_uri hostname")  | 
 | 142 | +    if req_redirect_uri_obj.path and not req_redirect_uri_obj.path.startswith("/"):  | 
 | 143 | +        raise URIError("Invalid redirect_uri path")  | 
 | 144 | +    try:  | 
 | 145 | +        req_redirect_uri_obj.port  | 
 | 146 | +    except ValueError as e:  | 
 | 147 | +        raise URIError(f"Invalid redirect_uri port: {str(e)}") from e  | 
 | 148 | + | 
 | 149 | +    uri_type_property = f"{uri_type}s" if uri_type == "redirect_uri" else uri_type  | 
 | 150 | +    client_redirect_uris: list[Union[str, tuple[str, dict]]] = client_info.get(uri_type_property)  | 
 | 151 | +    if not client_redirect_uris:  | 
 | 152 | +        # an OIDC client must have registered with redirect URIs  | 
 | 153 | +        if endpoint_type == "oidc":  | 
 | 154 | +            raise RedirectURIError(f"No registered {uri_type} for {client_id}")  | 
 | 155 | +        else:  | 
 | 156 | +            return  | 
 | 157 | + | 
 | 158 | +    # TODO move: this processing should be done during client registration/loading  | 
 | 159 | +    # TODO optimize: keep unique URIs (mayby use a set)  | 
 | 160 | +    # Pre-processing to homogenize the types of each item,  | 
 | 161 | +    # and normalize (lower-case, remove params, etc) the rediret URIs.  | 
 | 162 | +    # Each item is a tuple composed of:  | 
 | 163 | +    # - a ParseResult item, representing a URI without the query part, and  | 
 | 164 | +    # - a dict, representing a query string  | 
 | 165 | +    client_redirect_uris_obj: list[tuple[ParseResult, dict[str, list[str]]]] = [  | 
 | 166 | +        (  | 
 | 167 | +            urlparse(uri_base)._replace(query=None),  | 
 | 168 | +            (uri_qs_obj or {}),  | 
 | 169 | +        )  | 
 | 170 | +        for uri in client_redirect_uris  | 
 | 171 | +        for uri_base, uri_qs_obj in [(uri, {}) if isinstance(uri, str) else uri]  | 
 | 172 | +    ]  | 
 | 173 | + | 
 | 174 | +    # Handle redirect URIs for native clients:  | 
 | 175 | +    # When the URI is an http localhost (IPv4 or IPv6) literal, then  | 
 | 176 | +    # the port should not be taken into account when matching redirect URIs.  | 
 | 177 | +    client_type = client_info.get("application_type") or APPLICATION_TYPE_WEB  | 
 | 178 | +    if client_type == APPLICATION_TYPE_NATIVE:  | 
 | 179 | +        if is_http_uri(req_redirect_uri_obj) and is_localhost_uri(req_redirect_uri_obj):  | 
 | 180 | +            req_redirect_uri_obj = remove_port_from_uri(req_redirect_uri_obj)  | 
 | 181 | + | 
 | 182 | +        # TODO move: this processing should be done during client registration/loading  | 
 | 183 | +        # When the URI is an http localhost (IPv4 or IPv6) literal, then  | 
 | 184 | +        # the port should not be taken into account when matching redirect URIs.  | 
 | 185 | +        _client_redirect_uris_without_port_obj = []  | 
 | 186 | +        for uri_obj, url_qs_obj in client_redirect_uris_obj:  | 
 | 187 | +            if is_http_uri(uri_obj) and is_localhost_uri(uri_obj):  | 
 | 188 | +                uri_obj = remove_port_from_uri(uri_obj)  | 
 | 189 | +            _client_redirect_uris_without_port_obj.append((uri_obj, url_qs_obj))  | 
 | 190 | +        client_redirect_uris_obj = _client_redirect_uris_without_port_obj  | 
 | 191 | + | 
 | 192 | +    # Separate the URL from the query string object for the requested redirect URI.  | 
 | 193 | +    req_redirect_uri_query_obj = parse_qs(req_redirect_uri_obj.query)  | 
 | 194 | +    req_redirect_uri_without_query_obj = req_redirect_uri_obj._replace(query=None)  | 
 | 195 | + | 
 | 196 | +    match = any(  | 
 | 197 | +        req_redirect_uri_without_query_obj == uri_obj  | 
 | 198 | +        and req_redirect_uri_query_obj == uri_query_obj  | 
 | 199 | +        for uri_obj, uri_query_obj in client_redirect_uris_obj  | 
 | 200 | +    )  | 
 | 201 | +    if not match:  | 
 | 202 | +        raise RedirectURIError("Doesn't match any registered uris")  | 
 | 203 | + | 
129 | 204 | 
 
  | 
130 |  | -    # Get the clients registered redirect uris  | 
131 |  | -    client_info = context.cdb.get(_cid)  | 
132 |  | -    if client_info is None:  | 
133 |  | -        raise KeyError("No such client")  | 
 | 205 | +def is_http_uri(uri_obj: Union[ParseResult, SplitResult]) -> bool:  | 
 | 206 | +    value = uri_obj.scheme == "http"  | 
 | 207 | +    return value  | 
134 | 208 | 
 
  | 
135 |  | -    if uri_type == "redirect_uri":  | 
136 |  | -        redirect_uris = client_info.get(f"{uri_type}s")  | 
137 |  | -    else:  | 
138 |  | -        redirect_uris = client_info.get(f"{uri_type}")  | 
139 | 209 | 
 
  | 
140 |  | -    if redirect_uris is None:  | 
141 |  | -        if endpoint_type == "oidc":  | 
142 |  | -            raise RedirectURIError(f"No registered {uri_type} for {_cid}")  | 
143 |  | -    else:  | 
144 |  | -        match = False  | 
145 |  | -        for _item in redirect_uris:  | 
146 |  | -            if isinstance(_item, str):  | 
147 |  | -                regbase = _item  | 
148 |  | -                rquery = {}  | 
149 |  | -            else:  | 
150 |  | -                regbase, rquery = _item  | 
151 |  | - | 
152 |  | -            # The URI MUST exactly match one of the Redirection URI  | 
153 |  | -            if _base == regbase:  | 
154 |  | -                # every registered query component must exist in the uri  | 
155 |  | -                if rquery:  | 
156 |  | -                    if not _query:  | 
157 |  | -                        raise ValueError("Missing query part")  | 
158 |  | - | 
159 |  | -                    for key, vals in rquery.items():  | 
160 |  | -                        if key not in _query:  | 
161 |  | -                            raise ValueError('"{}" not in query part'.format(key))  | 
162 |  | - | 
163 |  | -                        for val in vals:  | 
164 |  | -                            if val not in _query[key]:  | 
165 |  | -                                raise ValueError("{}={} value not in query part".format(key, val))  | 
166 |  | - | 
167 |  | -                # and vice versa, every query component in the uri  | 
168 |  | -                # must be registered  | 
169 |  | -                if _query:  | 
170 |  | -                    if not rquery:  | 
171 |  | -                        raise ValueError("No registered query part")  | 
172 |  | - | 
173 |  | -                    for key, vals in _query.items():  | 
174 |  | -                        if key not in rquery:  | 
175 |  | -                            raise ValueError('"{}" extra in query part'.format(key))  | 
176 |  | -                        for val in vals:  | 
177 |  | -                            if val not in rquery[key]:  | 
178 |  | -                                raise ValueError("Extra {}={} value in query part".format(key, val))  | 
179 |  | -                match = True  | 
180 |  | -                break  | 
181 |  | -        if not match:  | 
182 |  | -            raise RedirectURIError("Doesn't match any registered uris")  | 
 | 210 | +def is_localhost_uri(uri_obj: Union[ParseResult, SplitResult]) -> bool:  | 
 | 211 | +    value = uri_obj.hostname in [  | 
 | 212 | +        "127.0.0.1",  | 
 | 213 | +        "::1",  | 
 | 214 | +        "0000:0000:0000:0000:0000:0000:0000:0001",  | 
 | 215 | +    ]  | 
 | 216 | +    return value  | 
 | 217 | + | 
 | 218 | + | 
 | 219 | +def remove_port_from_uri(uri_obj: ParsedURI) -> ParsedURI:  | 
 | 220 | +    if not uri_obj.port or not uri_obj.netloc:  | 
 | 221 | +        return uri_obj  | 
 | 222 | + | 
 | 223 | +    netloc_without_port = uri_obj.netloc.rsplit(":", 1)[0]  | 
 | 224 | +    uri_without_port_obj = uri_obj._replace(netloc=netloc_without_port)  | 
 | 225 | +    return uri_without_port_obj  | 
183 | 226 | 
 
  | 
184 | 227 | 
 
  | 
185 | 228 | def join_query(base, query):  | 
 | 
0 commit comments