1: c3698bbc3d = 1: 6434d90105 common/jsonapi: support FRONTEND clients 2: 0cd726fd55 < -: ---------- libpq: add OAUTHBEARER SASL mechanism -: ---------- > 2: 13ddf2b6b3 libpq: add OAUTHBEARER SASL mechanism 3: 77889eb986 = 3: 0b0b0f2b33 backend: add OAUTHBEARER SASL mechanism 4: 573a2ca3bc ! 4: 0e8ddadcbf Add pytest suite for OAuth @@ Commit message dependencies will be installed into ./venv for you. See the README for more details. + For iddawc, asynchronous tests still hang, as expected. Bad-interval + tests fail because iddawc apparently doesn't care that the interval is + bad. + ## src/test/python/.gitignore (new) ## @@ +__pycache__/ @@ src/test/python/client/conftest.py (new) +import threading + +import psycopg2 ++import psycopg2.extras +import pytest + +import pq3 @@ src/test/python/client/conftest.py (new) + def run(self): + try: + conn = psycopg2.connect(host="127.0.0.1", **self._kwargs) ++ self._pump_async(conn) + conn.close() + except Exception as e: + self.exception = e @@ src/test/python/client/conftest.py (new) + self.exception = None + raise e + ++ def _pump_async(self, conn): ++ """ ++ Polls a psycopg2 connection until it's completed. (Synchronous ++ connections will work here too; they'll just immediately return OK.) ++ """ ++ psycopg2.extras.wait_select(conn) ++ + +@pytest.fixture +def accept(server_socket): @@ src/test/python/client/conftest.py (new) + return sock, client + + yield factory -+ client.check_completed() ++ ++ if client is not None: ++ client.check_completed() + + +@pytest.fixture @@ src/test/python/client/test_oauth.py (new) @@ +# +# Copyright 2021 VMware, Inc. ++# Portions Copyright 2023 Timescale, Inc. +# SPDX-License-Identifier: PostgreSQL +# + @@ src/test/python/client/test_oauth.py (new) +import threading +import time +import urllib.parse ++from numbers import Number + +import psycopg2 +import pytest @@ src/test/python/client/test_oauth.py (new) + finish_handshake(conn) + + ++class RawResponse(str): ++ """ ++ Returned by registered endpoint callbacks to take full control of the ++ response. Usually, return values are converted to JSON; a RawResponse body ++ will be passed to the client as-is, allowing endpoint implementations to ++ issue invalid JSON. ++ """ ++ ++ pass ++ ++ +class OpenIDProvider(threading.Thread): + """ + A thread that runs a mock OpenID provider server. @@ src/test/python/client/test_oauth.py (new) + + def run(self): + try: -+ self.server.serve_forever() ++ # XXX socketserver.serve_forever() has a serious architectural ++ # issue: its select loop wakes up every `poll_interval` seconds to ++ # see if the server is shutting down. The default, 500 ms, only lets ++ # us run two tests every second. But the faster we go, the more CPU ++ # we burn unnecessarily... ++ self.server.serve_forever(poll_interval=0.01) + except Exception as e: + self.exception = e + @@ src/test/python/client/test_oauth.py (new) + self.endpoint_paths = {} + self._endpoints = {} + ++ # Provide a standard discovery document by default; tests can ++ # override it. ++ self.register_endpoint( ++ None, ++ "GET", ++ "/.well-known/openid-configuration", ++ self._default_discovery_handler, ++ ) ++ + def register_endpoint(self, name, method, path, func): + if method not in self._endpoints: + self._endpoints[method] = {} + + self._endpoints[method][path] = func -+ self.endpoint_paths[name] = path ++ ++ if name is not None: ++ self.endpoint_paths[name] = path + + def endpoint(self, method, path): + if method not in self._endpoints: @@ src/test/python/client/test_oauth.py (new) + + return self._endpoints[method].get(path) + ++ def _default_discovery_handler(self, headers, params): ++ doc = { ++ "issuer": self.issuer, ++ "response_types_supported": ["token"], ++ "subject_types_supported": ["public"], ++ "id_token_signing_alg_values_supported": ["RS256"], ++ "grant_types_supported": [ ++ "urn:ietf:params:oauth:grant-type:device_code" ++ ], ++ } ++ ++ for name, path in self.endpoint_paths.items(): ++ doc[name] = self.issuer + path ++ ++ return 200, doc ++ + class _Server(http.server.HTTPServer): + def handle_error(self, request, addr): + self.shutdown_request(request) @@ src/test/python/client/test_oauth.py (new) + class _Handler(http.server.BaseHTTPRequestHandler): + timeout = BLOCKING_TIMEOUT + -+ def _discovery_handler(self, headers, params): -+ oauth = self.server.oauth -+ -+ doc = { -+ "issuer": oauth.issuer, -+ "response_types_supported": ["token"], -+ "subject_types_supported": ["public"], -+ "id_token_signing_alg_values_supported": ["RS256"], -+ } -+ -+ for name, path in oauth.endpoint_paths.items(): -+ doc[name] = oauth.issuer + path -+ -+ return 200, doc -+ + def _handle(self, *, params=None, handler=None): + oauth = self.server.oauth + assert self.headers["Host"] == oauth.host @@ src/test/python/client/test_oauth.py (new) + handler is not None + ), f"no registered endpoint for {self.command} {self.path}" + -+ code, resp = handler(self.headers, params) ++ result = handler(self.headers, params) ++ ++ if len(result) == 2: ++ headers = {"Content-Type": "application/json"} ++ code, resp = result ++ else: ++ code, headers, resp = result + + self.send_response(code) -+ self.send_header("Content-Type", "application/json") ++ for h, v in headers.items(): ++ self.send_header(h, v) + self.end_headers() + -+ resp = json.dumps(resp) -+ resp = resp.encode("utf-8") -+ self.wfile.write(resp) ++ if resp is not None: ++ if not isinstance(resp, RawResponse): ++ resp = json.dumps(resp) ++ resp = resp.encode("utf-8") ++ self.wfile.write(resp) + + self.close_connection = True + + def do_GET(self): -+ if self.path == "/.well-known/openid-configuration": -+ self._handle(handler=self._discovery_handler) -+ return -+ + self._handle() + + def _request_body(self): @@ src/test/python/client/test_oauth.py (new) +@pytest.mark.parametrize("secret", [None, "", "hunter2"]) +@pytest.mark.parametrize("scope", [None, "", "openid email"]) +@pytest.mark.parametrize("retries", [0, 1]) ++@pytest.mark.parametrize( ++ "asynchronous", ++ [ ++ pytest.param(False, id="synchronous"), ++ pytest.param(True, id="asynchronous"), ++ ], ++) +def test_oauth_with_explicit_issuer( -+ capfd, accept, openid_provider, retries, scope, secret ++ capfd, accept, openid_provider, asynchronous, retries, scope, secret +): + client_id = secrets.token_hex() + @@ src/test/python/client/test_oauth.py (new) + oauth_client_id=client_id, + oauth_client_secret=secret, + oauth_scope=scope, ++ async_=asynchronous, + ) + + device_code = secrets.token_hex() @@ src/test/python/client/test_oauth.py (new) + assert expected in stderr + + -+def test_oauth_requires_client_id(accept, openid_provider): -+ sock, client = accept( -+ oauth_issuer=openid_provider.issuer, -+ # Do not set a client ID; this should cause a client error after the -+ # server asks for OAUTHBEARER and the client tries to contact the -+ # issuer. -+ ) -+ ++def expect_disconnected_handshake(sock): ++ """ ++ Helper for any tests that expect the client to disconnect immediately after ++ being sent the OAUTHBEARER SASL method. Generally speaking, this requires ++ the client to have an oauth_issuer set so that it doesn't try to go through ++ discovery. ++ """ + with sock: + with pq3.wrap(sock, debug_stream=sys.stdout) as conn: + # Initiate a handshake. @@ src/test/python/client/test_oauth.py (new) + # The client should disconnect at this point. + assert not conn.read() + ++ ++def test_oauth_requires_client_id(accept, openid_provider): ++ sock, client = accept( ++ oauth_issuer=openid_provider.issuer, ++ # Do not set a client ID; this should cause a client error after the ++ # server asks for OAUTHBEARER and the client tries to contact the ++ # issuer. ++ ) ++ ++ expect_disconnected_handshake(sock) ++ + expected_error = "no oauth_client_id is set" + with pytest.raises(psycopg2.OperationalError, match=expected_error): + client.check_completed() @@ src/test/python/client/test_oauth.py (new) + finish_handshake(conn) + + ++def alt_patterns(*patterns): ++ """ ++ Just combines multiple alternative regexes into one. It's not very efficient ++ but IMO it's easier to read and maintain. ++ """ ++ pat = "" ++ ++ for p in patterns: ++ if pat: ++ pat += "|" ++ pat += f"({p})" ++ ++ return pat ++ ++ +@pytest.mark.parametrize( + "failure_mode, error_pattern", + [ + pytest.param( -+ { -+ "error": "invalid_client", -+ "error_description": "client authentication failed", -+ }, -+ r"client authentication failed \(invalid_client\)", ++ ( ++ 400, ++ { ++ "error": "invalid_client", ++ "error_description": "client authentication failed", ++ }, ++ ), ++ r"failed to obtain device authorization: client authentication failed \(invalid_client\)", + id="authentication failure with description", + ), + pytest.param( -+ {"error": "invalid_request"}, -+ r"\(invalid_request\)", ++ (400, {"error": "invalid_request"}), ++ r"failed to obtain device authorization: \(invalid_request\)", + id="invalid request without description", + ), + pytest.param( -+ {}, -+ r"failed to obtain device authorization", ++ (400, {}), ++ alt_patterns( ++ r'failed to parse token error response: field "error" is missing', ++ r"failed to obtain device authorization: \(iddawc error I_ERROR_PARAM\)", ++ ), + id="broken error response", + ), ++ pytest.param( ++ (200, RawResponse(r'{ "interval": 3.5.8 }')), ++ alt_patterns( ++ r"failed to parse device authorization: Token .* is invalid", ++ r"failed to obtain device authorization: \(iddawc error I_ERROR\)", ++ ), ++ id="non-numeric interval", ++ ), ++ pytest.param( ++ (200, RawResponse(r'{ "interval": 08 }')), ++ alt_patterns( ++ r"failed to parse device authorization: Token .* is invalid", ++ r"failed to obtain device authorization: \(iddawc error I_ERROR\)", ++ ), ++ id="invalid numeric interval", ++ ), + ], +) +def test_oauth_device_authorization_failures( @@ src/test/python/client/test_oauth.py (new) + # any unprotected state mutation here. + + def authorization_endpoint(headers, params): -+ return 400, failure_mode ++ return failure_mode + + openid_provider.register_endpoint( + "device_authorization_endpoint", "POST", "/device", authorization_endpoint @@ src/test/python/client/test_oauth.py (new) + "token_endpoint", "POST", "/token", token_endpoint + ) + -+ with sock: -+ with pq3.wrap(sock, debug_stream=sys.stdout) as conn: -+ # Initiate a handshake, which should result in the above endpoints -+ # being called. -+ startup = pq3.recv1(conn, cls=pq3.Startup) -+ assert startup.proto == pq3.protocol(3, 0) ++ expect_disconnected_handshake(sock) + -+ pq3.send( -+ conn, -+ pq3.types.AuthnRequest, -+ type=pq3.authn.SASL, -+ body=[b"OAUTHBEARER", b""], -+ ) ++ # Now make sure the client correctly failed. ++ with pytest.raises(psycopg2.OperationalError, match=error_pattern): ++ client.check_completed() + -+ # The client should not continue the connection due to the hardcoded -+ # provider failure; we disconnect here. ++ ++Missing = object() # sentinel for test_oauth_device_authorization_bad_json() ++ ++ ++@pytest.mark.parametrize( ++ "bad_value", ++ [ ++ pytest.param({"device_code": 3}, id="object"), ++ pytest.param([1, 2, 3], id="array"), ++ pytest.param("some string", id="string"), ++ pytest.param(4, id="numeric"), ++ pytest.param(False, id="boolean"), ++ pytest.param(None, id="null"), ++ pytest.param(Missing, id="missing"), ++ ], ++) ++@pytest.mark.parametrize( ++ "field_name,ok_type,required", ++ [ ++ ("device_code", str, True), ++ ("user_code", str, True), ++ ("verification_uri", str, True), ++ ("interval", int, False), ++ ], ++) ++def test_oauth_device_authorization_bad_json_schema( ++ accept, openid_provider, field_name, ok_type, required, bad_value ++): ++ # To make the test matrix easy, just skip the tests that aren't actually ++ # interesting (field of the correct type, missing optional field). ++ if bad_value is Missing and not required: ++ pytest.skip("not interesting: optional field") ++ elif type(bad_value) == ok_type: # not isinstance(), because bool is an int ++ pytest.skip("not interesting: correct type") ++ ++ sock, client = accept( ++ oauth_issuer=openid_provider.issuer, ++ oauth_client_id=secrets.token_hex(), ++ ) ++ ++ # Set up our provider callbacks. ++ # NOTE that these callbacks will be called on a background thread. Don't do ++ # any unprotected state mutation here. ++ ++ def authorization_endpoint(headers, params): ++ # Begin with an acceptable base response... ++ resp = { ++ "device_code": "my-device-code", ++ "user_code": "my-user-code", ++ "interval": 0, ++ "verification_uri": "https://example.com", ++ "expires_in": 5, ++ } ++ ++ # ...then tweak it so the client fails. ++ if bad_value is Missing: ++ del resp[field_name] ++ else: ++ resp[field_name] = bad_value ++ ++ return 200, resp ++ ++ openid_provider.register_endpoint( ++ "device_authorization_endpoint", "POST", "/device", authorization_endpoint ++ ) ++ ++ def token_endpoint(headers, params): ++ assert False, "token endpoint was invoked unexpectedly" ++ ++ openid_provider.register_endpoint( ++ "token_endpoint", "POST", "/token", token_endpoint ++ ) ++ ++ expect_disconnected_handshake(sock) + + # Now make sure the client correctly failed. ++ if bad_value is Missing: ++ error_pattern = f'field "{field_name}" is missing' ++ elif ok_type == str: ++ error_pattern = f'field "{field_name}" must be a string' ++ elif ok_type == int: ++ error_pattern = f'field "{field_name}" must be a number' ++ else: ++ assert False, "update error_pattern for new failure mode" ++ ++ # XXX iddawc doesn't really check for problems in the device authorization ++ # response, leading to this patchwork: ++ if field_name == "verification_uri": ++ error_pattern = alt_patterns( ++ error_pattern, ++ "issuer did not provide a verification URI", ++ ) ++ elif field_name == "user_code": ++ error_pattern = alt_patterns( ++ error_pattern, ++ "issuer did not provide a user code", ++ ) ++ else: ++ error_pattern = alt_patterns( ++ error_pattern, ++ r"failed to obtain access token: \(iddawc error I_ERROR_PARAM\)", ++ ) ++ + with pytest.raises(psycopg2.OperationalError, match=error_pattern): + client.check_completed() + @@ src/test/python/client/test_oauth.py (new) + "failure_mode, error_pattern", + [ + pytest.param( -+ { -+ "error": "expired_token", -+ "error_description": "the device code has expired", -+ }, -+ r"the device code has expired \(expired_token\)", ++ ( ++ 400, ++ { ++ "error": "expired_token", ++ "error_description": "the device code has expired", ++ }, ++ ), ++ r"failed to obtain access token: the device code has expired \(expired_token\)", + id="expired token with description", + ), + pytest.param( -+ {"error": "access_denied"}, -+ r"\(access_denied\)", ++ (400, {"error": "access_denied"}), ++ r"failed to obtain access token: \(access_denied\)", + id="access denied without description", + ), + pytest.param( -+ {}, -+ r"OAuth token retrieval failed", -+ id="broken error response", ++ (400, {}), ++ alt_patterns( ++ r'failed to parse token error response: field "error" is missing', ++ r"failed to obtain access token: \(iddawc error I_ERROR_PARAM\)", ++ ), ++ id="empty error response", ++ ), ++ pytest.param( ++ (200, {}, {}), ++ alt_patterns( ++ r"failed to parse access token response: no content type was provided", ++ r"failed to obtain access token: \(iddawc error I_ERROR\)", ++ ), ++ id="missing content type", ++ ), ++ pytest.param( ++ (200, {"Content-Type": "text/plain"}, {}), ++ alt_patterns( ++ r"failed to parse access token response: unexpected content type", ++ r"failed to obtain access token: \(iddawc error I_ERROR\)", ++ ), ++ id="wrong content type", + ), + ], +) @@ src/test/python/client/test_oauth.py (new) + ) + + retry_lock = threading.Lock() ++ final_sent = False + + def token_endpoint(headers, params): + with retry_lock: -+ nonlocal retries ++ nonlocal retries, final_sent + + # If the test wants to force the client to retry, return an + # authorization_pending response and decrement the retry count. @@ src/test/python/client/test_oauth.py (new) + retries -= 1 + return 400, {"error": "authorization_pending"} + -+ return 400, failure_mode ++ # We should only return our failure_mode response once; any further ++ # requests indicate that the client isn't correctly bailing out. ++ assert not final_sent, "client continued after token error" ++ ++ final_sent = True ++ ++ return failure_mode + + openid_provider.register_endpoint( + "token_endpoint", "POST", "/token", token_endpoint + ) + -+ with sock: -+ with pq3.wrap(sock, debug_stream=sys.stdout) as conn: -+ # Initiate a handshake, which should result in the above endpoints -+ # being called. -+ startup = pq3.recv1(conn, cls=pq3.Startup) -+ assert startup.proto == pq3.protocol(3, 0) ++ expect_disconnected_handshake(sock) + -+ pq3.send( -+ conn, -+ pq3.types.AuthnRequest, -+ type=pq3.authn.SASL, -+ body=[b"OAUTHBEARER", b""], -+ ) ++ # Now make sure the client correctly failed. ++ with pytest.raises(psycopg2.OperationalError, match=error_pattern): ++ client.check_completed() + -+ # The client should not continue the connection due to the hardcoded -+ # provider failure; we disconnect here. ++ ++@pytest.mark.parametrize( ++ "bad_value", ++ [ ++ pytest.param({"device_code": 3}, id="object"), ++ pytest.param([1, 2, 3], id="array"), ++ pytest.param("some string", id="string"), ++ pytest.param(4, id="numeric"), ++ pytest.param(False, id="boolean"), ++ pytest.param(None, id="null"), ++ pytest.param(Missing, id="missing"), ++ ], ++) ++@pytest.mark.parametrize( ++ "field_name,ok_type,required", ++ [ ++ ("access_token", str, True), ++ ("token_type", str, True), ++ ], ++) ++def test_oauth_token_bad_json_schema( ++ accept, openid_provider, field_name, ok_type, required, bad_value ++): ++ # To make the test matrix easy, just skip the tests that aren't actually ++ # interesting (field of the correct type, missing optional field). ++ if bad_value is Missing and not required: ++ pytest.skip("not interesting: optional field") ++ elif type(bad_value) == ok_type: # not isinstance(), because bool is an int ++ pytest.skip("not interesting: correct type") ++ ++ sock, client = accept( ++ oauth_issuer=openid_provider.issuer, ++ oauth_client_id=secrets.token_hex(), ++ ) ++ ++ # Set up our provider callbacks. ++ # NOTE that these callbacks will be called on a background thread. Don't do ++ # any unprotected state mutation here. ++ ++ def authorization_endpoint(headers, params): ++ resp = { ++ "device_code": "my-device-code", ++ "user_code": "my-user-code", ++ "interval": 0, ++ "verification_uri": "https://example.com", ++ "expires_in": 5, ++ } ++ ++ return 200, resp ++ ++ openid_provider.register_endpoint( ++ "device_authorization_endpoint", "POST", "/device", authorization_endpoint ++ ) ++ ++ def token_endpoint(headers, params): ++ # Begin with an acceptable base response... ++ resp = { ++ "access_token": secrets.token_urlsafe(), ++ "token_type": "bearer", ++ } ++ ++ # ...then tweak it so the client fails. ++ if bad_value is Missing: ++ del resp[field_name] ++ else: ++ resp[field_name] = bad_value ++ ++ return 200, resp ++ ++ openid_provider.register_endpoint( ++ "token_endpoint", "POST", "/token", token_endpoint ++ ) ++ ++ expect_disconnected_handshake(sock) + + # Now make sure the client correctly failed. ++ error_pattern = "failed to parse access token response: " ++ if bad_value is Missing: ++ error_pattern += f'field "{field_name}" is missing' ++ elif ok_type == str: ++ error_pattern += f'field "{field_name}" must be a string' ++ elif ok_type == int: ++ error_pattern += f'field "{field_name}" must be a number' ++ else: ++ assert False, "update error_pattern for new failure mode" ++ ++ # XXX iddawc is fairly silent on the topic. ++ error_pattern = alt_patterns( ++ error_pattern, ++ r"failed to obtain access token: \(iddawc error I_ERROR_PARAM\)", ++ ) ++ + with pytest.raises(psycopg2.OperationalError, match=error_pattern): + client.check_completed() + @@ src/test/python/client/test_oauth.py (new) + + +@pytest.mark.parametrize( ++ "bad_response,expected_error", ++ [ ++ pytest.param( ++ (200, {"Content-Type": "text/plain"}, {}), ++ r'failed to parse OpenID discovery document: unexpected content type "text/plain"', ++ id="not JSON", ++ ), ++ pytest.param( ++ (200, {}, {}), ++ r"failed to parse OpenID discovery document: no content type was provided", ++ id="no Content-Type", ++ ), ++ pytest.param( ++ (204, {}, None), ++ r"failed to fetch OpenID discovery document: unexpected response code 204", ++ id="no content", ++ ), ++ pytest.param( ++ (301, {"Location": "https://localhost/"}, None), ++ r"failed to fetch OpenID discovery document: unexpected response code 301", ++ id="redirection", ++ ), ++ pytest.param( ++ (404, {}), ++ r"failed to fetch OpenID discovery document: unexpected response code 404", ++ id="not found", ++ ), ++ pytest.param( ++ (200, RawResponse("blah\x00blah")), ++ r"failed to parse OpenID discovery document: response contains embedded NULLs", ++ id="NULL bytes in document", ++ ), ++ pytest.param( ++ (200, 123), ++ r"failed to parse OpenID discovery document: top-level element must be an object", ++ id="scalar at top level", ++ ), ++ pytest.param( ++ (200, []), ++ r"failed to parse OpenID discovery document: top-level element must be an object", ++ id="array at top level", ++ ), ++ pytest.param( ++ (200, RawResponse("{")), ++ r"failed to parse OpenID discovery document.* input string ended unexpectedly", ++ id="unclosed object", ++ ), ++ pytest.param( ++ (200, RawResponse(r'{ "hello": ] }')), ++ r"failed to parse OpenID discovery document.* Expected JSON value", ++ id="bad array", ++ ), ++ pytest.param( ++ (200, {"issuer": 123}), ++ r'failed to parse OpenID discovery document: field "issuer" must be a string', ++ id="non-string issuer", ++ ), ++ pytest.param( ++ (200, {"issuer": ["something"]}), ++ r'failed to parse OpenID discovery document: field "issuer" must be a string', ++ id="issuer array", ++ ), ++ pytest.param( ++ (200, {"issuer": {}}), ++ r'failed to parse OpenID discovery document: field "issuer" must be a string', ++ id="issuer object", ++ ), ++ pytest.param( ++ (200, {"grant_types_supported": 123}), ++ r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings', ++ id="scalar grant types field", ++ ), ++ pytest.param( ++ (200, {"grant_types_supported": {}}), ++ r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings', ++ id="object grant types field", ++ ), ++ pytest.param( ++ (200, {"grant_types_supported": [123]}), ++ r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings', ++ id="non-string grant types", ++ ), ++ pytest.param( ++ (200, {"grant_types_supported": ["something", 123]}), ++ r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings', ++ id="non-string grant types later in the list", ++ ), ++ pytest.param( ++ (200, {"grant_types_supported": ["something", {}]}), ++ r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings', ++ id="object grant types later in the list", ++ ), ++ pytest.param( ++ (200, {"grant_types_supported": ["something", ["something"]]}), ++ r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings', ++ id="embedded array grant types later in the list", ++ ), ++ pytest.param( ++ ( ++ 200, ++ { ++ "grant_types_supported": ["something"], ++ "token_endpoint": "https://example.com/", ++ "issuer": 123, ++ }, ++ ), ++ r'failed to parse OpenID discovery document: field "issuer" must be a string', ++ id="non-string issuer after other valid fields", ++ ), ++ pytest.param( ++ ( ++ 200, ++ { ++ "ignored": {"grant_types_supported": 123, "token_endpoint": 123}, ++ "issuer": 123, ++ }, ++ ), ++ r'failed to parse OpenID discovery document: field "issuer" must be a string', ++ id="non-string issuer after other ignored fields", ++ ), ++ pytest.param( ++ (200, {"token_endpoint": "https://example.com/"}), ++ r'failed to parse OpenID discovery document: field "issuer" is missing', ++ id="missing issuer", ++ ), ++ pytest.param( ++ (200, {"issuer": "https://example.com/"}), ++ r'failed to parse OpenID discovery document: field "token_endpoint" is missing', ++ id="missing token endpoint", ++ ), ++ pytest.param( ++ ( ++ 200, ++ { ++ "issuer": "https://example.com", ++ "token_endpoint": "https://example.com/token", ++ "device_authorization_endpoint": "https://example.com/dev", ++ }, ++ ), ++ r'cannot run OAuth device authorization: issuer "https://example.com" does not support device code grants', ++ id="missing device code grants", ++ ), ++ pytest.param( ++ ( ++ 200, ++ { ++ "issuer": "https://example.com", ++ "token_endpoint": "https://example.com/token", ++ "grant_types_supported": [ ++ "urn:ietf:params:oauth:grant-type:device_code" ++ ], ++ }, ++ ), ++ r'cannot run OAuth device authorization: issuer "https://example.com" does not provide a device authorization endpoint', ++ id="missing device_authorization_endpoint", ++ ), ++ # ++ # Exercise HTTP-level failures by breaking the protocol. Note that the ++ # error messages here are implementation-dependent. ++ # ++ pytest.param( ++ (1000, {}), ++ r"failed to fetch OpenID discovery document: Unsupported protocol \(.*\)", ++ id="invalid HTTP response code", ++ ), ++ pytest.param( ++ (200, {"Content-Length": -1}, {}), ++ r"failed to fetch OpenID discovery document: Weird server reply \(.*Content-Length.*\)", ++ id="bad HTTP Content-Length", ++ ), ++ ], ++) ++def test_oauth_discovery_provider_failure( ++ accept, openid_provider, bad_response, expected_error ++): ++ sock, client = accept( ++ oauth_issuer=openid_provider.issuer, ++ oauth_client_id=secrets.token_hex(), ++ ) ++ ++ def failing_discovery_handler(headers, params): ++ return bad_response ++ ++ openid_provider.register_endpoint( ++ None, ++ "GET", ++ "/.well-known/openid-configuration", ++ failing_discovery_handler, ++ ) ++ ++ expect_disconnected_handshake(sock) ++ ++ # XXX iddawc doesn't differentiate... ++ expected_error = alt_patterns( ++ expected_error, ++ r"failed to fetch OpenID discovery document \(iddawc error I_ERROR(_PARAM)?\)", ++ ) ++ ++ with pytest.raises(psycopg2.OperationalError, match=expected_error): ++ client.check_completed() ++ ++ ++@pytest.mark.parametrize( + "sasl_err,resp_type,resp_payload,expected_error", + [ + pytest.param( @@ src/test/python/client/test_oauth.py (new) + "server sent additional OAuth data", + id="broken server: SASL success after error", + ), ++ pytest.param( ++ {"status": "invalid_request"}, ++ pq3.types.AuthnRequest, ++ dict(type=pq3.authn.SASL, body=[b"OAUTHBEARER", b""]), ++ "duplicate SASL authentication request", ++ id="broken server: SASL reinitialization after error", ++ ), + ], +) +def test_oauth_server_error(accept, sasl_err, resp_type, resp_payload, expected_error): 5: 4490d029b5 ! 5: 65c319a6a3 squash! Add pytest suite for OAuth @@ .cirrus.yml: task: sysctl kern.corefile='/tmp/cores/%N.%P.core' setup_additional_packages_script: | - #pkg install -y ... -+ pkg install -y iddawc ++ pkg install -y curl # NB: Intentionally build without -Dllvm. The freebsd image size is already # large enough to make VM startup slow, and even without llvm freebsd @@ .cirrus.yml: task: --buildtype=debug \ -Dcassert=true -Duuid=bsd -Dtcl_version=tcl86 -Ddtrace=auto \ -DPG_TEST_EXTRA="$PG_TEST_EXTRA" \ -+ -Doauth=enabled \ ++ -Doauth=curl \ -Dextra_lib_dirs=/usr/local/lib -Dextra_include_dirs=/usr/local/include/ \ build EOF @@ .cirrus.yml: LINUX_CONFIGURE_FEATURES: &LINUX_CONFIGURE_FEATURES >- --with-libxslt --with-llvm --with-lz4 -+ --with-oauth ++ --with-oauth=curl --with-pam --with-perl --with-python @@ .cirrus.yml: LINUX_CONFIGURE_FEATURES: &LINUX_CONFIGURE_FEATURES >- LINUX_MESON_FEATURES: &LINUX_MESON_FEATURES >- -Dllvm=enabled -+ -Doauth=enabled ++ -Doauth=curl -Duuid=e2fs @@ .cirrus.yml: task: - #DEBIAN_FRONTEND=noninteractive apt-get -y install ... + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get -y install \ -+ libiddawc-dev \ -+ libiddawc-dev:i386 \ ++ libcurl4-openssl-dev \ ++ libcurl4-openssl-dev:i386 \ + python3-venv \ matrix: @@ .cirrus.yml: task: - #apt-get update - #DEBIAN_FRONTEND=noninteractive apt-get -y install ... + apt-get update -+ DEBIAN_FRONTEND=noninteractive apt-get -y install libiddawc-dev ++ DEBIAN_FRONTEND=noninteractive apt-get -y install libcurl4-openssl-dev ### # Test that code can be built with gcc/clang without warnings @@ meson.build: foreach test_dir : tests + test_group = test_dir['name'] + test_output = test_result_dir / test_group / kind + test_kwargs = { -+ 'protocol': 'tap', ++ #'protocol': 'tap', + 'suite': test_group, + 'timeout': 1000, + 'depends': test_deps, @@ meson.build: foreach test_dir : tests + pytest = venv_path / 'bin' / 'py.test' + test_command = [ + pytest, ++ # Avoid running these tests against an existing database. ++ '--temp-instance', test_output / 'tmp_check', ++ + # FIXME pytest-tap's stream feature accidentally suppresses errors that + # are critical for debugging: + # https://github.com/python-tap/pytest-tap/issues/30 -+ # Fix -- or maybe don't use the meson TAP protocol for now? -+ '--tap-stream', -+ # Avoid running these tests against an existing database. -+ '--temp-instance', test_output / 'tmp_check', ++ # Don't use the meson TAP protocol for now... ++ #'--tap-stream', + ] + + foreach pyt : t['tests'] @@ src/test/python/client/test_oauth.py: import pq3 +# The client tests need libpq to have been compiled with OAuth support; skip +# them otherwise. +pytestmark = pytest.mark.skipif( -+ os.getenv("with_oauth") == "no", ++ os.getenv("with_oauth") == "none", + reason="OAuth client tests require --with-oauth support", +) + @@ src/test/python/meson.build (new) + './test_pq3.py', + ], + 'env': { -+ 'with_oauth': oauth.found() ? 'yes' : 'no', ++ 'with_oauth': oauth_library, + + # Point to the default database; the tests will create their own databases + # as needed.