1: f2ba496e26 ! 1: dc4f869365 Add OAUTHBEARER SASL mechanism @@ doc/src/sgml/oauth-validators.sgml (new) + + + -+ Implementing OAuth Validator Modules ++ OAuth Validator Modules + + OAuth Validators + + + PostgreSQL provides infrastructure for creating -+ custom modules to perform server-side validation of OAuth tokens. ++ custom modules to perform server-side validation of OAuth bearer tokens. + + + OAuth validation modules must at least consist of an initialization function @@ doc/src/sgml/oauth-validators.sgml (new) + + Validate Callback + ++ The validate_cb callback is executed during the OAuth ++ exchange when a user attempts to authenticate using OAuth. Any state set in ++ previous calls will be available in state->private_data. ++ + +typedef ValidatorModuleResult *(*ValidatorValidateCB) (ValidatorModuleState *state, const char *token, const char *role); + ++ ++ token will contain the bearer token to validate. ++ The server has ensured that the token is well-formed syntactically, but no ++ other validation has been performed. role will ++ contain the role the user has requested to log in as. The callback must ++ return a palloc'd ValidatorModuleResult struct, which is ++ defined as below: ++ ++ ++typedef struct ValidatorModuleResult ++{ ++ bool authorized; ++ char *authn_id; ++} ValidatorModuleResult; ++ ++ ++ The connection will only proceed if the module sets ++ authorized to true. To ++ authenticate the user, the authenticated user name (as determined using the ++ token) shall be palloc'd and returned in the authn_id ++ field. Alternatively, authn_id may be set to ++ NULL if the token is valid but the associated user identity cannot be ++ determined. ++ ++ ++ The caller assumes ownership of the returned memory allocation, the ++ validator module should not in any way access the memory after it has been ++ returned. A validator may instead return NULL to signal an internal ++ error. ++ ++ ++ The behavior after validate_cb returns depends on the ++ specific HBA setup. Normally, the authn_id user ++ name must exactly match the role that the user is logging in as. (This ++ behavior may be modified with a usermap.) But when authenticating against ++ an HBA rule with trust_validator_authz turned on, the ++ server will not perform any checks on the value of ++ authn_id at all; in this case it is up to the ++ validator to ensure that the token carries enough privileges for the user to ++ log in under the indicated role. + + + @@ src/backend/libpq/auth-oauth.c (new) +oauth_exchange(void *opaq, const char *input, int inputlen, + char **output, int *outputlen, const char **logdetail) +{ ++ char *input_copy; + char *p; + char cbind_flag; + char *auth; ++ int status; + + struct oauth_ctx *ctx = opaq; + @@ src/backend/libpq/auth-oauth.c (new) + } + + /* Handle the client's initial message. */ -+ p = pstrdup(input); ++ p = input_copy = pstrdup(input); + + /* + * OAUTHBEARER does not currently define a channel binding (so there is no @@ src/backend/libpq/auth-oauth.c (new) + generate_error_response(ctx, output, outputlen); + + ctx->state = OAUTH_STATE_ERROR; -+ return PG_SASL_EXCHANGE_CONTINUE; ++ status = PG_SASL_EXCHANGE_CONTINUE; ++ } ++ else ++ { ++ ctx->state = OAUTH_STATE_FINISHED; ++ status = PG_SASL_EXCHANGE_SUCCESS; + } + -+ ctx->state = OAUTH_STATE_FINISHED; -+ return PG_SASL_EXCHANGE_SUCCESS; ++ /* Don't let extra copies of the bearer token hang around. */ ++ explicit_bzero(input_copy, inputlen); ++ ++ return status; +} + +/* @@ src/backend/libpq/auth-oauth.c (new) + int map_status; + ValidatorModuleResult *ret; + const char *token; ++ bool status; + + /* Ensure that we have a correct token to validate */ + if (!(token = validate_token_format(auth))) @@ src/backend/libpq/auth-oauth.c (new) + /* Call the validation function from the validator module */ + ret = ValidatorCallbacks->validate_cb(validator_module_state, + token, port->user_name); ++ if (ret == NULL) ++ { ++ ereport(LOG, errmsg("Internal error in OAuth validator module")); ++ return false; ++ } + + if (!ret->authorized) -+ return false; ++ { ++ status = false; ++ goto cleanup; ++ } + + if (ret->authn_id) + set_authn_id(port, ret->authn_id); @@ src/backend/libpq/auth-oauth.c (new) + * validator implementation; all that matters is that the validator + * says the user can log in with the target role. + */ -+ return true; ++ status = true; ++ goto cleanup; + } + + /* Make sure the validator authenticated the user. */ @@ src/backend/libpq/auth-oauth.c (new) + errmsg("OAuth bearer authentication failed for user \"%s\"", + port->user_name), + errdetail_log("Validator provided no identity.")); -+ return false; ++ ++ status = false; ++ goto cleanup; + } + + /* Finally, check the user map. */ + map_status = check_usermap(port->hba->usermap, port->user_name, + MyClientConnectionInfo.authn_id, false); -+ return (map_status == STATUS_OK); ++ status = (map_status == STATUS_OK); ++ ++cleanup: ++ ++ /* ++ * Clear and free the validation result from the validator module once ++ * we're done with it. ++ */ ++ if (ret->authn_id != NULL) ++ pfree(ret->authn_id); ++ pfree(ret); ++ ++ return status; +} + +/* @@ src/backend/libpq/hba.c: parse_hba_line(TokenizedAuthLine *tok_line, int elevel) + MANDATORY_AUTH_ARG(parsedline->oauth_issuer, "issuer", "oauth"); + + /* -+ * Supplying a usermap combined with the option to skip usermapping -+ * is nonsensical and indicates a configuration error. ++ * Supplying a usermap combined with the option to skip usermapping is ++ * nonsensical and indicates a configuration error. + */ + if (parsedline->oauth_skip_usermap && parsedline->usermap != NULL) + { + ereport(elevel, + errcode(ERRCODE_CONFIG_FILE_ERROR), -+ /* translator: strings are replaced with hba options */ ++ /* translator: strings are replaced with hba options */ + errmsg("%s cannot be used in combination with %s", + "map", "trust_validator_authz"), + errcontext("line %d of configuration file \"%s\"", @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new) +} + +/* ++ * URL-Encoding Helpers ++ */ ++ ++/* ++ * Encodes a string using the application/x-www-form-urlencoded format, and ++ * appends it to the given buffer. ++ */ ++static void ++append_urlencoded(PQExpBuffer buf, const char *s) ++{ ++ char *escaped; ++ char *haystack; ++ char *match; ++ ++ escaped = curl_easy_escape(NULL, s, 0); ++ if (!escaped) ++ { ++ markPQExpBufferBroken(buf); ++ return; ++ } ++ ++ /* ++ * curl_easy_escape() almost does what we want, but we need the ++ * query-specific flavor which uses '+' instead of '%20' for spaces. The ++ * Curl command-line tool does this with a simple search-and-replace, so ++ * follow its lead. ++ */ ++ haystack = escaped; ++ ++ while ((match = strstr(haystack, "%20")) != NULL) ++ { ++ /* Append the unmatched portion, followed by the plus sign. */ ++ appendBinaryPQExpBuffer(buf, haystack, match - haystack); ++ appendPQExpBufferChar(buf, '+'); ++ ++ /* Keep searching after the match. */ ++ haystack = match + 3 /* strlen("%20") */ ; ++ } ++ ++ /* Push the remainder of the string onto the buffer. */ ++ appendPQExpBufferStr(buf, haystack); ++ ++ curl_free(escaped); ++} ++ ++/* ++ * Convenience wrapper for encoding a single string. Returns NULL on allocation ++ * failure. ++ */ ++static char * ++urlencode(const char *s) ++{ ++ PQExpBufferData buf; ++ ++ initPQExpBuffer(&buf); ++ append_urlencoded(&buf, s); ++ ++ return PQExpBufferDataBroken(buf) ? NULL : buf.data; ++} ++ ++/* ++ * Appends a key/value pair to the end of an application/x-www-form-urlencoded ++ * list. ++ */ ++static void ++build_urlencoded(PQExpBuffer buf, const char *key, const char *value) ++{ ++ if (buf->len) ++ appendPQExpBufferChar(buf, '&'); ++ ++ append_urlencoded(buf, key); ++ appendPQExpBufferChar(buf, '='); ++ append_urlencoded(buf, value); ++} ++ ++/* + * Specific HTTP Request Handlers + * + * This is finally the beginning of the actual application logic. Generally @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new) +} + +/* ++ * Adds the client ID (and secret, if provided) to the current request, using ++ * either HTTP headers or the request body. ++ */ ++static bool ++add_client_identification(struct async_ctx *actx, PQExpBuffer reqbody, PGconn *conn) ++{ ++ bool success = false; ++ char *username = NULL; ++ char *password = NULL; ++ ++ if (conn->oauth_client_secret) /* Zero-length secrets are permitted! */ ++ { ++ /*---- ++ * Use HTTP Basic auth to send the client_id and secret. Per RFC 6749, ++ * Sec. 2.3.1, ++ * ++ * Including the client credentials in the request-body using the ++ * two parameters is NOT RECOMMENDED and SHOULD be limited to ++ * clients unable to directly utilize the HTTP Basic authentication ++ * scheme (or other password-based HTTP authentication schemes). ++ * ++ * Additionally: ++ * ++ * The client identifier is encoded using the ++ * "application/x-www-form-urlencoded" encoding algorithm per Appendix ++ * B, and the encoded value is used as the username; the client ++ * password is encoded using the same algorithm and used as the ++ * password. ++ * ++ * (Appendix B modifies application/x-www-form-urlencoded by requiring ++ * an initial UTF-8 encoding step. Since the client ID and secret must ++ * both be 7-bit ASCII -- RFC 6749 Appendix A -- we don't worry about ++ * that in this function.) ++ * ++ * client_id is not added to the request body in this case. Not only ++ * would it be redundant, but some providers in the wild (e.g. Okta) ++ * refuse to accept it. ++ */ ++ username = urlencode(conn->oauth_client_id); ++ password = urlencode(conn->oauth_client_secret); ++ ++ if (!username || !password) ++ { ++ actx_error(actx, "out of memory"); ++ goto cleanup; ++ } ++ ++ CHECK_SETOPT(actx, CURLOPT_HTTPAUTH, CURLAUTH_BASIC, goto cleanup); ++ CHECK_SETOPT(actx, CURLOPT_USERNAME, username, goto cleanup); ++ CHECK_SETOPT(actx, CURLOPT_PASSWORD, password, goto cleanup); ++ ++ actx->used_basic_auth = true; ++ } ++ else ++ { ++ /* ++ * If we're not otherwise authenticating, client_id is REQUIRED in the ++ * request body. ++ */ ++ build_urlencoded(reqbody, "client_id", conn->oauth_client_id); ++ ++ CHECK_SETOPT(actx, CURLOPT_HTTPAUTH, CURLAUTH_NONE, goto cleanup); ++ actx->used_basic_auth = false; ++ } ++ ++ success = true; ++ ++cleanup: ++ free(username); ++ free(password); ++ ++ return success; ++} ++ ++/* + * Queue a Device Authorization Request: + * + * https://www.rfc-editor.org/rfc/rfc8628#section-3.1 @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new) + Assert(conn->oauth_client_id); /* ensured by get_auth_token() */ + Assert(device_authz_uri); /* ensured by check_for_device_flow() */ + -+ /* Construct our request body. TODO: url-encode */ ++ /* Construct our request body. */ + resetPQExpBuffer(work_buffer); -+ appendPQExpBuffer(work_buffer, "client_id=%s", conn->oauth_client_id); -+ if (conn->oauth_scope) -+ appendPQExpBuffer(work_buffer, "&scope=%s", conn->oauth_scope); ++ if (conn->oauth_scope && conn->oauth_scope[0]) ++ build_urlencoded(work_buffer, "scope", conn->oauth_scope); ++ ++ if (!add_client_identification(actx, work_buffer, conn)) ++ return false; + + if (PQExpBufferBroken(work_buffer)) + { @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new) + CHECK_SETOPT(actx, CURLOPT_URL, device_authz_uri, return false); + CHECK_SETOPT(actx, CURLOPT_COPYPOSTFIELDS, work_buffer->data, return false); + -+ if (conn->oauth_client_secret) -+ { -+ /*---- -+ * Use HTTP Basic auth to send the password. Per RFC 6749, Sec. 2.3.1, -+ * -+ * Including the client credentials in the request-body using the -+ * two parameters is NOT RECOMMENDED and SHOULD be limited to -+ * clients unable to directly utilize the HTTP Basic authentication -+ * scheme (or other password-based HTTP authentication schemes). -+ * -+ * TODO: should we omit client_id from the body in this case? -+ * TODO: url-encode...? -+ */ -+ CHECK_SETOPT(actx, CURLOPT_HTTPAUTH, CURLAUTH_BASIC, return false); -+ CHECK_SETOPT(actx, CURLOPT_USERNAME, conn->oauth_client_id, return false); -+ CHECK_SETOPT(actx, CURLOPT_PASSWORD, conn->oauth_client_secret, return false); -+ -+ actx->used_basic_auth = true; -+ } -+ else -+ { -+ CHECK_SETOPT(actx, CURLOPT_HTTPAUTH, CURLAUTH_NONE, return false); -+ actx->used_basic_auth = false; -+ } -+ + return start_request(actx); +} + @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new) + Assert(token_uri); /* ensured by get_discovery_document() */ + Assert(device_code); /* ensured by run_device_authz() */ + -+ /* Construct our request body. TODO: url-encode */ ++ /* Construct our request body. */ + resetPQExpBuffer(work_buffer); -+ appendPQExpBuffer(work_buffer, "client_id=%s", conn->oauth_client_id); -+ appendPQExpBuffer(work_buffer, "&device_code=%s", device_code); -+ appendPQExpBuffer(work_buffer, "&grant_type=%s", -+ OAUTH_GRANT_TYPE_DEVICE_CODE); -+ /* TODO check for broken buffer */ -+ -+ /* Make our request. */ -+ CHECK_SETOPT(actx, CURLOPT_URL, token_uri, return false); -+ CHECK_SETOPT(actx, CURLOPT_COPYPOSTFIELDS, work_buffer->data, return false); ++ build_urlencoded(work_buffer, "device_code", device_code); ++ build_urlencoded(work_buffer, "grant_type", OAUTH_GRANT_TYPE_DEVICE_CODE); + -+ if (conn->oauth_client_secret) -+ { -+ /*---- -+ * Use HTTP Basic auth to send the password. Per RFC 6749, Sec. 2.3.1, -+ * -+ * Including the client credentials in the request-body using the -+ * two parameters is NOT RECOMMENDED and SHOULD be limited to -+ * clients unable to directly utilize the HTTP Basic authentication -+ * scheme (or other password-based HTTP authentication schemes). -+ * -+ * TODO: should we omit client_id from the body in this case? -+ * TODO: url-encode...? -+ */ -+ CHECK_SETOPT(actx, CURLOPT_HTTPAUTH, CURLAUTH_BASIC, return false); -+ CHECK_SETOPT(actx, CURLOPT_USERNAME, conn->oauth_client_id, return false); -+ CHECK_SETOPT(actx, CURLOPT_PASSWORD, conn->oauth_client_secret, return false); ++ if (!add_client_identification(actx, work_buffer, conn)) ++ return false; + -+ actx->used_basic_auth = true; -+ } -+ else ++ if (PQExpBufferBroken(work_buffer)) + { -+ CHECK_SETOPT(actx, CURLOPT_HTTPAUTH, CURLAUTH_NONE, return false); -+ actx->used_basic_auth = false; ++ actx_error(actx, "out of memory"); ++ return false; + } + -+ resetPQExpBuffer(work_buffer); -+ CHECK_SETOPT(actx, CURLOPT_WRITEFUNCTION, append_data, return false); -+ CHECK_SETOPT(actx, CURLOPT_WRITEDATA, actx, return false); ++ /* Make our request. */ ++ CHECK_SETOPT(actx, CURLOPT_URL, token_uri, return false); ++ CHECK_SETOPT(actx, CURLOPT_COPYPOSTFIELDS, work_buffer->data, return false); + + return start_request(actx); +} @@ src/interfaces/libpq/meson.build: if gssapi.found() kwargs: gen_export_kwargs, ) + ## src/interfaces/libpq/pqexpbuffer.c ## +@@ src/interfaces/libpq/pqexpbuffer.c: static const char *const oom_buffer_ptr = oom_buffer; + * + * Put a PQExpBuffer in "broken" state if it isn't already. + */ +-static void ++void + markPQExpBufferBroken(PQExpBuffer str) + { + if (str->data != oom_buffer) + + ## src/interfaces/libpq/pqexpbuffer.h ## +@@ src/interfaces/libpq/pqexpbuffer.h: extern void initPQExpBuffer(PQExpBuffer str); + extern void destroyPQExpBuffer(PQExpBuffer str); + extern void termPQExpBuffer(PQExpBuffer str); + ++/*------------------------ ++ * markPQExpBufferBroken ++ * Put a PQExpBuffer in "broken" state if it isn't already. ++ */ ++extern void markPQExpBufferBroken(PQExpBuffer str); ++ + /*------------------------ + * resetPQExpBuffer + * Reset a PQExpBuffer to empty + ## src/makefiles/meson.build ## @@ src/makefiles/meson.build: pgxs_deps = { 'llvm': llvm, @@ src/test/modules/oauth_validator/t/001_server.pl (new) + $log_start = $log_end; +} + ++# Make sure the client_id and secret are correctly encoded. $vschars contains ++# every allowed character for a client_id/_secret (the "VSCHAR" class). ++# $vschars_esc is additionally backslash-escaped for inclusion in a ++# single-quoted connection string. ++my $vschars = ++ " !\"#\$%&'()*+,-./0123456789:;<=>?\@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"; ++my $vschars_esc = ++ " !\"#\$%&\\'()*+,-./0123456789:;<=>?\@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"; ++ ++$node->connect_ok( ++ "user=$user dbname=postgres oauth_client_id='$vschars_esc'", ++ "escapable characters: client_id", ++ expected_stderr => ++ qr@Visit https://example\.org/ and enter the code: postgresuser@); ++$node->connect_ok( ++ "user=$user dbname=postgres oauth_client_id='$vschars_esc' oauth_client_secret='$vschars_esc'", ++ "escapable characters: client_id and secret", ++ expected_stderr => ++ qr@Visit https://example\.org/ and enter the code: postgresuser@); ++ +# +# Further tests rely on support for specific behaviors in oauth_server.py. To +# trigger these behaviors, we ask for the special issuer .../param (which is set @@ src/test/modules/oauth_validator/t/001_server.pl (new) +# + +my $common_connstr = "user=testparam dbname=postgres "; ++my $base_connstr = $common_connstr; + +sub connstr +{ @@ src/test/modules/oauth_validator/t/001_server.pl (new) + my $json = encode_json(\%params); + my $encoded = encode_base64($json, ""); + -+ return "$common_connstr oauth_client_id=$encoded"; ++ return "$base_connstr oauth_client_id=$encoded"; +} + +# Make sure the param system works end-to-end first. @@ src/test/modules/oauth_validator/t/001_server.pl (new) + expected_stderr => qr/bearer authentication failed/); + +# Test behavior of the oauth_client_secret. -+$common_connstr = "$common_connstr oauth_client_secret=12345"; ++$base_connstr = "$common_connstr oauth_client_secret=''"; ++ ++$node->connect_ok( ++ connstr(stage => 'all', expected_secret => ''), ++ "empty oauth_client_secret", ++ expected_stderr => ++ qr@Visit https://example\.com/ and enter the code: postgresuser@); ++ ++$base_connstr = "$common_connstr oauth_client_secret='$vschars_esc'"; + +$node->connect_ok( -+ connstr(stage => 'all', expected_secret => '12345'), -+ "oauth_client_secret", ++ connstr(stage => 'all', expected_secret => $vschars), ++ "nonempty oauth_client_secret", + expected_stderr => + qr@Visit https://example\.com/ and enter the code: postgresuser@); + @@ src/test/modules/oauth_validator/t/001_server.pl (new) +# + +# Searching the logs is easier if OAuth parameter discovery isn't cluttering -+# things up; hardcode the issuer. -+$common_connstr = "user=test dbname=postgres oauth_issuer=$issuer"; ++# things up; hardcode the issuer. (Scope is hardcoded to empty to cover that ++# case as well.) ++$common_connstr = ++ "user=test dbname=postgres oauth_issuer=$issuer oauth_scope=''"; + +$bgconn->query_safe("ALTER SYSTEM SET oauth_validator.authn_id TO ''"); +$node->reload; @@ src/test/modules/oauth_validator/t/oauth_server.py (new) + if secret is None: + return + -+ if "Authorization" not in self.headers: -+ raise RuntimeError("client did not send Authorization header") -+ ++ assert "Authorization" in self.headers + method, creds = self.headers["Authorization"].split() + + if method != "Basic": + raise RuntimeError(f"client used {method} auth; expected Basic") + -+ expected_creds = f"{self.client_id}:{secret}" ++ username = urllib.parse.quote_plus(self.client_id) ++ password = urllib.parse.quote_plus(secret) ++ expected_creds = f"{username}:{password}" ++ + if creds.encode() != base64.b64encode(expected_creds.encode()): + raise RuntimeError( + f"client sent '{creds}'; expected b64encode('{expected_creds}')" @@ src/test/modules/oauth_validator/t/oauth_server.py (new) + form = self.rfile.read(size) + + assert self.headers["Content-Type"] == "application/x-www-form-urlencoded" -+ return urllib.parse.parse_qs(form.decode("utf-8"), strict_parsing=True) ++ return urllib.parse.parse_qs( ++ form.decode("utf-8"), ++ strict_parsing=True, ++ keep_blank_values=True, ++ encoding="utf-8", ++ errors="strict", ++ ) + + @property + def client_id(self) -> str: + """ -+ Returns the client_id sent in the POST body. self._parse_params() must -+ have been called first. ++ Returns the client_id sent in the POST body or the Authorization header. ++ self._parse_params() must have been called first. + """ -+ return self._params["client_id"][0] ++ if "client_id" in self._params: ++ return self._params["client_id"][0] ++ ++ if "Authorization" not in self.headers: ++ raise RuntimeError("client did not send any client_id") ++ ++ _, creds = self.headers["Authorization"].split() ++ ++ decoded = base64.b64decode(creds).decode("utf-8") ++ username, _ = decoded.split(":", 1) ++ ++ return urllib.parse.unquote_plus(username) + + def do_POST(self): + self._response_code = 200 @@ src/test/modules/oauth_validator/t/oauth_server.py (new) + else: + self._token_state.min_delay = 5 # default + ++ # Check the scope. ++ if "scope" in self._params: ++ assert self._params["scope"][0], "empty scopes should be omitted" ++ + return resp + + def token(self) -> JsonObject: 2: 55068dfb46 < -: ---------- v30-review-comments 3: c01dbdd6cb ! 2: cca5de6726 DO NOT MERGE: Add pytest suite for OAuth @@ src/test/python/client/test_oauth.py (new) + assert self.headers["Content-Type"] == "application/x-www-form-urlencoded" + + body = self._request_body() -+ params = urllib.parse.parse_qs(body) ++ if body: ++ # parse_qs() is understandably fairly lax when it comes to ++ # acceptable characters, but we're stricter. Spaces must be ++ # encoded, and they must use the '+' encoding rather than "%20". ++ assert " " not in body ++ assert "%20" not in body ++ ++ params = urllib.parse.parse_qs( ++ body, ++ keep_blank_values=True, ++ strict_parsing=True, ++ encoding="utf-8", ++ errors="strict", ++ ) ++ else: ++ params = {} + + self._handle(params=params) + @@ src/test/python/client/test_oauth.py (new) + access_token = secrets.token_urlsafe() + + def check_client_authn(headers, params): -+ if not secret: ++ if secret is None: ++ assert "Authorization" not in headers + assert params["client_id"] == [client_id] + return + + # Require the client to use Basic authn; request-body credentials are + # NOT RECOMMENDED (RFC 6749, Sec. 2.3.1). + assert "Authorization" in headers ++ assert "client_id" not in params + + method, creds = headers["Authorization"].split() + assert method == "Basic" @@ src/test/python/client/test_oauth.py (new) + client.check_completed() + + ++# See https://datatracker.ietf.org/doc/html/rfc6749#appendix-A for character ++# class definitions. ++all_vschars = "".join([chr(c) for c in range(0x20, 0x7F)]) ++all_nqchars = "".join([chr(c) for c in range(0x21, 0x7F) if c not in (0x22, 0x5C)]) ++ ++ ++@pytest.mark.parametrize("client_id", ["", ":", " + ", r'+=&"\/~', all_vschars]) ++@pytest.mark.parametrize("secret", [None, "", ":", " + ", r'+=&"\/~', all_vschars]) ++@pytest.mark.parametrize("device_code", ["", " + ", r'+=&"\/~', all_vschars]) ++@pytest.mark.parametrize("scope", ["&", r"+=&/", all_nqchars]) ++def test_url_encoding(accept, openid_provider, client_id, secret, device_code, scope): ++ sock, client = accept( ++ oauth_issuer=openid_provider.issuer, ++ oauth_client_id=client_id, ++ oauth_client_secret=secret, ++ oauth_scope=scope, ++ ) ++ ++ user_code = f"{secrets.token_hex(2)}-{secrets.token_hex(2)}" ++ verification_url = "https://example.com/device" ++ ++ access_token = secrets.token_urlsafe() ++ ++ def check_client_authn(headers, params): ++ if secret is None: ++ assert "Authorization" not in headers ++ assert params["client_id"] == [client_id] ++ return ++ ++ # Require the client to use Basic authn; request-body credentials are ++ # NOT RECOMMENDED (RFC 6749, Sec. 2.3.1). ++ assert "Authorization" in headers ++ assert "client_id" not in params ++ ++ method, creds = headers["Authorization"].split() ++ assert method == "Basic" ++ ++ decoded = base64.b64decode(creds).decode("utf-8") ++ username, password = decoded.split(":", 1) ++ ++ expected_username = urllib.parse.quote_plus(client_id) ++ expected_password = urllib.parse.quote_plus(secret) ++ ++ assert [username, password] == [expected_username, expected_password] ++ ++ # 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): ++ check_client_authn(headers, params) ++ ++ if scope: ++ assert params["scope"] == [scope] ++ else: ++ assert "scope" not in params ++ ++ resp = { ++ "device_code": device_code, ++ "user_code": user_code, ++ "interval": 0, ++ "verification_url": verification_url, ++ "expires_in": 5, ++ } ++ ++ return 200, resp ++ ++ openid_provider.register_endpoint( ++ "device_authorization_endpoint", "POST", "/device", authorization_endpoint ++ ) ++ ++ def token_endpoint(headers, params): ++ check_client_authn(headers, params) ++ ++ assert params["grant_type"] == ["urn:ietf:params:oauth:grant-type:device_code"] ++ assert params["device_code"] == [device_code] ++ ++ # Successfully finish the request by sending the access bearer token. ++ resp = { ++ "access_token": access_token, ++ "token_type": "bearer", ++ } ++ ++ return 200, resp ++ ++ 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. ++ initial = start_oauth_handshake(conn) ++ ++ # Validate and accept the token. ++ auth = get_auth_value(initial) ++ assert auth == f"Bearer {access_token}".encode("ascii") ++ ++ pq3.send(conn, pq3.types.AuthnRequest, type=pq3.authn.SASLFinal) ++ finish_handshake(conn) ++ ++ +@pytest.mark.slow +@pytest.mark.parametrize("error_code", ["authorization_pending", "slow_down"]) +@pytest.mark.parametrize("retries", [1, 2])