commit 604a14d9204d00dd246951523ebfc24f4f3a7422
parent 0ed94333a35a9f22c6b18c9cf96a0d9bde404ff6
Author: Christian Grothoff <christian@grothoff.org>
Date: Thu, 6 Aug 2026 14:58:09 +0200
RFC 6750 requires 401 for both malformed and unknown tokens
Diffstat:
3 files changed, 207 insertions(+), 16 deletions(-)
diff --git a/src/challenger/challenger-httpd_info.c b/src/challenger/challenger-httpd_info.c
@@ -38,6 +38,29 @@
*/
#define MAX_RETRIES 3
+
+/**
+ * Reply to @a connection that the bearer token is not (or no longer)
+ * valid, as per RFC 6750 Section 3.1. Used both for tokens that do not
+ * even decode and for tokens that are simply unknown, so that the reply
+ * does not first reveal whether a guessed token is well-formed and only
+ * then whether it exists.
+ *
+ * @param connection connection to reply on
+ * @return MHD result code
+ */
+static enum MHD_Result
+reply_invalid_token (struct MHD_Connection *connection)
+{
+ return CH_reply_with_oauth_error (
+ connection,
+ MHD_HTTP_UNAUTHORIZED,
+ "invalid_token",
+ TALER_EC_CHALLENGER_GRANT_UNKNOWN,
+ NULL);
+}
+
+
enum MHD_Result
CH_handler_info (struct CH_HandlerContext *hc,
const char *upload_data,
@@ -55,10 +78,12 @@ CH_handler_info (struct CH_HandlerContext *hc,
if (NULL == auth)
{
GNUNET_break_op (0);
- return TALER_MHD_reply_with_error (hc->connection,
- MHD_HTTP_FORBIDDEN,
- TALER_EC_GENERIC_PARAMETER_MISSING,
- MHD_HTTP_HEADER_AUTHORIZATION);
+ return CH_reply_with_oauth_error (
+ hc->connection,
+ MHD_HTTP_UNAUTHORIZED,
+ "invalid_request",
+ TALER_EC_GENERIC_PARAMETER_MISSING,
+ MHD_HTTP_HEADER_AUTHORIZATION);
}
/* RFC 7235: the auth-scheme token ("Bearer") is case-insensitive. */
if (0 != strncasecmp (auth,
@@ -66,10 +91,12 @@ CH_handler_info (struct CH_HandlerContext *hc,
strlen (BEARER_PREFIX)))
{
GNUNET_break_op (0);
- return TALER_MHD_reply_with_error (hc->connection,
- MHD_HTTP_FORBIDDEN,
- TALER_EC_GENERIC_PARAMETER_MALFORMED,
- MHD_HTTP_HEADER_AUTHORIZATION);
+ return CH_reply_with_oauth_error (
+ hc->connection,
+ MHD_HTTP_UNAUTHORIZED,
+ "invalid_request",
+ TALER_EC_GENERIC_PARAMETER_MALFORMED,
+ MHD_HTTP_HEADER_AUTHORIZATION);
}
token = auth + strlen (BEARER_PREFIX);
@@ -80,10 +107,7 @@ CH_handler_info (struct CH_HandlerContext *hc,
sizeof (grant)))
{
GNUNET_break_op (0);
- return TALER_MHD_reply_with_error (hc->connection,
- MHD_HTTP_FORBIDDEN,
- TALER_EC_GENERIC_PARAMETER_MALFORMED,
- MHD_HTTP_HEADER_AUTHORIZATION);
+ return reply_invalid_token (hc->connection);
}
/* Check token is valid */
@@ -116,10 +140,7 @@ CH_handler_info (struct CH_HandlerContext *hc,
TALER_EC_GENERIC_DB_FETCH_FAILED,
"get_token");
case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS:
- return TALER_MHD_reply_with_error (hc->connection,
- MHD_HTTP_NOT_FOUND,
- TALER_EC_CHALLENGER_GRANT_UNKNOWN,
- "get_token");
+ return reply_invalid_token (hc->connection);
case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT:
break;
}
diff --git a/src/challenger/meson.build b/src/challenger/meson.build
@@ -19,6 +19,7 @@ check_SCRIPTS = [
'test-challenger-pinfail',
'test-challenger-resend',
'test-challenger-exhaustion',
+ 'test-challenger-auth-errors',
]
test_helper_cat = configure_file(input: 'cat.sh', output: 'cat.sh', copy: true)
diff --git a/src/challenger/test-challenger-auth-errors.sh b/src/challenger/test-challenger-auth-errors.sh
@@ -0,0 +1,169 @@
+#!/usr/bin/env bash
+# This file is in the public domain.
+#
+# Regression test for finding 13: 'GET /info' must implement the RFC 6750
+# bearer-token error contract.
+#
+# A missing 'Authorization' header, a token that does not decode and a
+# well-formed but unknown token were answered 403, 403 and 404
+# respectively, never 401, and never with a 'WWW-Authenticate' header --
+# so a conforming OAuth client cannot tell "refresh your token" from
+# "resource gone", and the 403/404 split reveals whether a guessed token
+# is well-formed before revealing whether it exists.
+#
+# All of these must now be 401 with a 'WWW-Authenticate: Bearer error=...'
+# header, and the malformed and the unknown token must be answered
+# identically.
+
+set -eu
+
+# Exit, with status code "skip" (no 'real' failure)
+function exit_skip() {
+ echo " SKIP: $1"
+ exit 77
+}
+
+# Exit, with error message (hard failure)
+function exit_fail() {
+ echo " FAIL: $@"
+ exit 1
+}
+
+# Cleanup to run whenever we exit
+function cleanup()
+{
+ for n in $(jobs -p)
+ do
+ kill $n 2> /dev/null || true
+ done
+ rm -f "$LAST_RESPONSE" "$LAST_HEADERS"
+ wait
+}
+
+LAST_RESPONSE=$(mktemp responseXXXXXX.log)
+LAST_HEADERS=$(mktemp headersXXXXXX.log)
+
+# Install cleanup handler (except for kill -9)
+trap cleanup EXIT
+
+export PATH="$PATH:."
+
+echo -n "Testing for jq"
+jq -h > /dev/null || exit_skip "jq required"
+echo " FOUND"
+echo -n "Testing for curl"
+curl -h > /dev/null || exit_skip "curl required"
+echo " FOUND"
+echo -n "Testing for wget"
+wget -h > /dev/null || exit_skip "wget required"
+echo " FOUND"
+echo -n "Testing for challenger-httpd ..."
+challenger-httpd -h > /dev/null || exit_skip "challenger-httpd required"
+echo " FOUND"
+
+CONF="test-challenger.conf"
+BURL="http://localhost:9967"
+REDIRECT_URI="http://client.example.com/"
+CLIENT_SECRET="secret-token:secret"
+CLIENT_ID=1
+
+echo -n "Initialize challenger database ..."
+challenger-dbinit -r -c "${CONF}" &> dbinit.log
+echo " OK"
+
+echo -n "Add challenger client ..."
+challenger-admin -c "${CONF}" -a "${CLIENT_SECRET}" "${REDIRECT_URI}" &> admin.log
+echo " OK"
+
+echo -n "Start challenger-httpd ..."
+challenger-httpd -L INFO -c "${CONF}" &> httpd.log &
+
+# Wait for challenger to be available
+for n in $(seq 1 50)
+do
+ echo -n "."
+ sleep 0.2
+ OK=0
+ wget --tries=1 --timeout=1 "${BURL}/config" -o /dev/null -O /dev/null >/dev/null || continue
+ OK=1
+ break
+done
+if [ 1 != $OK ]
+then
+ exit_skip "Failed to launch challenger service"
+fi
+echo " OK"
+
+# Check that the last response carried a 'WWW-Authenticate: Bearer' header
+# naming the given OAuth error.
+function check_www_authenticate() {
+ if ! grep -qi "^WWW-Authenticate:" "$LAST_HEADERS"
+ then
+ exit_fail "missing WWW-Authenticate header: $(cat $LAST_HEADERS)"
+ fi
+ if ! grep -qi "^WWW-Authenticate:.*Bearer.*error=\"$1\"" "$LAST_HEADERS"
+ then
+ exit_fail "expected WWW-Authenticate 'Bearer error=\"$1\"'. Got: $(grep -i '^WWW-Authenticate:' $LAST_HEADERS)"
+ fi
+}
+
+echo -n "/info without an Authorization header ..."
+STATUS=$(curl "${BURL}/info" \
+ -D "$LAST_HEADERS" \
+ -w "%{http_code}" -s -o $LAST_RESPONSE)
+if [ "$STATUS" != "401" ]
+then
+ exit_fail "Expected 401 Unauthorized. Got: $STATUS" $(cat $LAST_RESPONSE)
+fi
+check_www_authenticate "invalid_request"
+echo " OK"
+
+echo -n "/info with a non-Bearer Authorization header ..."
+STATUS=$(curl "${BURL}/info" \
+ -H "Authorization: Basic dXNlcjpwYXNz" \
+ -D "$LAST_HEADERS" \
+ -w "%{http_code}" -s -o $LAST_RESPONSE)
+if [ "$STATUS" != "401" ]
+then
+ exit_fail "Expected 401 Unauthorized. Got: $STATUS" $(cat $LAST_RESPONSE)
+fi
+check_www_authenticate "invalid_request"
+echo " OK"
+
+echo -n "/info with a token that does not decode ..."
+STATUS=$(curl "${BURL}/info" \
+ -H "Authorization: Bearer not-a-base32-token" \
+ -D "$LAST_HEADERS" \
+ -w "%{http_code}" -s -o $LAST_RESPONSE)
+if [ "$STATUS" != "401" ]
+then
+ exit_fail "Expected 401 Unauthorized. Got: $STATUS" $(cat $LAST_RESPONSE)
+fi
+check_www_authenticate "invalid_token"
+MALFORMED_STATUS="$STATUS"
+MALFORMED_BODY=$(cat "$LAST_RESPONSE")
+echo " OK"
+
+echo -n "/info with a well-formed but unknown token ..."
+# 32 zero bytes in GNUnet's base32 alphabet
+UNKNOWN_TOKEN=$(printf '0%.0s' $(seq 1 52))
+STATUS=$(curl "${BURL}/info" \
+ -H "Authorization: Bearer ${UNKNOWN_TOKEN}" \
+ -D "$LAST_HEADERS" \
+ -w "%{http_code}" -s -o $LAST_RESPONSE)
+if [ "$STATUS" != "401" ]
+then
+ exit_fail "Expected 401 Unauthorized. Got: $STATUS" $(cat $LAST_RESPONSE)
+fi
+check_www_authenticate "invalid_token"
+echo " OK"
+
+echo -n "Malformed and unknown tokens must be indistinguishable ..."
+if [ "$STATUS" != "$MALFORMED_STATUS" ] || \
+ [ "$(cat $LAST_RESPONSE)" != "$MALFORMED_BODY" ]
+then
+ exit_fail "a malformed token is answered differently from an unknown one: '$MALFORMED_BODY' vs '$(cat $LAST_RESPONSE)'"
+fi
+echo " OK"
+
+exit 0