|
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