since-v7.diff.txt
text/plain
Filename: since-v7.diff.txt
Type: text/plain
Part: 0
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.