|
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