commit 82780cee92875a6278ae33f70b3f008de7a54ba1
parent e5f0aa0e4b92febfbeb29f639ce616bc51119ca3
Author: Christian Grothoff <christian@grothoff.org>
Date: Sun, 26 Apr 2026 09:31:35 +0200
handle case where challenge is revisited after being solved correctly, add test for it
Diffstat:
7 files changed, 411 insertions(+), 172 deletions(-)
diff --git a/src/challenger/Makefile.am b/src/challenger/Makefile.am
@@ -29,7 +29,8 @@ bin_SCRIPTS = \
check_SCRIPTS = \
test-challenger.sh \
- test-challenger-pkce.sh
+ test-challenger-pkce.sh \
+ test-challenger-revisit.sh
TESTS = \
$(check_SCRIPTS)
diff --git a/src/challenger/challenger-httpd_challenge.c b/src/challenger/challenger-httpd_challenge.c
@@ -337,11 +337,11 @@ send_tan (struct ChallengeContext *bc)
enum MHD_Result mres;
GNUNET_break (0);
- mres = reply_error (bc,
- "internal-error",
- MHD_HTTP_BAD_GATEWAY,
- TALER_EC_CHALLENGER_HELPER_EXEC_FAILED,
- "pipe");
+ mres = TALER_MHD_reply_with_error (
+ bc->hc->connection,
+ MHD_HTTP_BAD_GATEWAY,
+ TALER_EC_CHALLENGER_HELPER_EXEC_FAILED,
+ "pipe");
bc->status = (MHD_YES == mres)
? GNUNET_NO
: GNUNET_SYSERR;
@@ -385,11 +385,11 @@ send_tan (struct ChallengeContext *bc)
GNUNET_break (0);
GNUNET_break (GNUNET_OK ==
GNUNET_DISK_pipe_close (p));
- mres = reply_error (bc,
- "internal-error",
- MHD_HTTP_BAD_GATEWAY,
- TALER_EC_CHALLENGER_HELPER_EXEC_FAILED,
- "exec");
+ mres = TALER_MHD_reply_with_error (
+ bc->hc->connection,
+ MHD_HTTP_BAD_GATEWAY,
+ TALER_EC_CHALLENGER_HELPER_EXEC_FAILED,
+ "exec");
bc->status = (MHD_YES == mres)
? GNUNET_NO
: GNUNET_SYSERR;
@@ -421,11 +421,11 @@ send_tan (struct ChallengeContext *bc)
enum MHD_Result mres;
GNUNET_break (0);
- mres = reply_error (bc,
- "internal-error",
- MHD_HTTP_INTERNAL_SERVER_ERROR,
- TALER_EC_GENERIC_FAILED_TO_EXPAND_TEMPLATE,
- NULL);
+ mres = TALER_MHD_reply_with_error (
+ bc->hc->connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_FAILED_TO_EXPAND_TEMPLATE,
+ NULL);
GNUNET_DISK_file_close (pipe_stdin);
bc->status = (MHD_YES == mres)
? GNUNET_NO
@@ -459,11 +459,11 @@ send_tan (struct ChallengeContext *bc)
enum MHD_Result mres;
GNUNET_break (0);
- mres = reply_error (bc,
- "internal-error",
- MHD_HTTP_BAD_GATEWAY,
- TALER_EC_CHALLENGER_HELPER_EXEC_FAILED,
- "write");
+ mres = TALER_MHD_reply_with_error (
+ bc->hc->connection,
+ MHD_HTTP_BAD_GATEWAY,
+ TALER_EC_CHALLENGER_HELPER_EXEC_FAILED,
+ "write");
GNUNET_DISK_file_close (pipe_stdin);
GNUNET_free (msg);
bc->status = (MHD_YES == mres)
@@ -640,11 +640,11 @@ CH_handler_challenge (struct CH_HandlerContext *hc,
sizeof (bc->nonce)))
{
GNUNET_break_op (0);
- return reply_error (bc,
- "invalid-request",
- MHD_HTTP_NOT_FOUND,
- TALER_EC_GENERIC_PARAMETER_MISSING,
- hc->path);
+ return TALER_MHD_reply_with_error (
+ hc->connection,
+ MHD_HTTP_NOT_FOUND,
+ TALER_EC_GENERIC_PARAMETER_MISSING,
+ hc->path);
}
{
const char *ct;
@@ -698,11 +698,11 @@ CH_handler_challenge (struct CH_HandlerContext *hc,
"Helper failed with status %d/%d\n",
(int) bc->pst,
(int) bc->exit_code);
- return reply_error (bc,
- "internal-error",
- MHD_HTTP_BAD_GATEWAY,
- TALER_EC_CHALLENGER_HELPER_EXEC_FAILED,
- es);
+ return TALER_MHD_reply_with_error (
+ hc->connection,
+ MHD_HTTP_BAD_GATEWAY,
+ TALER_EC_CHALLENGER_HELPER_EXEC_FAILED,
+ es);
}
/* handle upload */
if (bc->is_json)
@@ -880,21 +880,24 @@ CH_handler_challenge (struct CH_HandlerContext *hc,
bc->solved ? "solved" : "unsolved");
if (bc->solved)
{
+ enum GNUNET_GenericReturnValue ret;
+ char *url;
struct MHD_Response *response;
- enum MHD_Result ret;
-
- // FIXME: this "redirect_url" is incomplete, we need to compute
- // the full one with 'code' and possibly 'state' as is done
- // in challenger-httpd_solve.c!
- json_t *args = GNUNET_JSON_PACK (
+ json_t *args;
+
+ ret = CH_build_full_redirect_url (&bc->nonce,
+ hc->connection,
+ &url);
+ if (GNUNET_OK != ret)
+ return (GNUNET_NO == ret) ? MHD_YES : MHD_NO;
+ args = GNUNET_JSON_PACK (
GNUNET_JSON_pack_string ("type",
"completed"),
GNUNET_JSON_pack_string ("redirect_url",
- bc->client_redirect_uri)
+ url)
);
-
+ GNUNET_free (url);
response = TALER_MHD_make_json (args);
-
ret = MHD_queue_response (hc->connection,
MHD_HTTP_OK,
response);
diff --git a/src/challenger/challenger-httpd_common.c b/src/challenger/challenger-httpd_common.c
@@ -20,6 +20,8 @@
*/
#include "platform.h"
#include "challenger-httpd_common.h"
+#include <gnunet/gnunet_pq_lib.h>
+#include "challenger-database/validation_get.h"
/**
@@ -277,3 +279,83 @@ TALER_MHD_redirect_with_oauth_status (
return ret;
}
}
+
+
+enum GNUNET_GenericReturnValue
+CH_build_full_redirect_url (const struct CHALLENGER_ValidationNonceP *nonce,
+ struct MHD_Connection *connection,
+ char **url)
+{
+ char *client_secret;
+ json_t *address;
+ char *client_scope;
+ char *client_state;
+ char *client_redirect_uri;
+ enum GNUNET_DB_QueryStatus qs;
+ enum MHD_Result ret;
+
+ qs = CHALLENGERDB_validation_get (CH_context,
+ nonce,
+ &client_secret,
+ &address,
+ &client_scope,
+ &client_state,
+ &client_redirect_uri);
+ switch (qs)
+ {
+ case GNUNET_DB_STATUS_HARD_ERROR:
+ case GNUNET_DB_STATUS_SOFT_ERROR:
+ GNUNET_break (0);
+ ret = TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_FETCH_FAILED,
+ "validation_get");
+ return (MHD_NO == ret) ? GNUNET_SYSERR : GNUNET_NO;
+ case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS:
+ GNUNET_break (0);
+ ret = TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_NOT_FOUND,
+ TALER_EC_CHALLENGER_GENERIC_VALIDATION_UNKNOWN,
+ NULL);
+ return (MHD_NO == ret) ? GNUNET_SYSERR : GNUNET_NO;
+ case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT:
+ break;
+ }
+ {
+ char *code;
+
+ code = CH_compute_code (nonce,
+ client_secret,
+ client_scope,
+ address,
+ client_redirect_uri);
+ if (NULL == client_state)
+ {
+ GNUNET_asprintf (url,
+ "%s?code=%s",
+ client_redirect_uri,
+ code);
+ }
+ else
+ {
+ char *url_encoded;
+
+ url_encoded = TALER_urlencode (client_state);
+ GNUNET_asprintf (url,
+ "%s?code=%s&state=%s",
+ client_redirect_uri,
+ code,
+ url_encoded);
+ GNUNET_free (url_encoded);
+ }
+ GNUNET_free (code);
+ }
+ json_decref (address);
+ GNUNET_free (client_scope);
+ GNUNET_free (client_secret);
+ GNUNET_free (client_redirect_uri);
+ GNUNET_free (client_state);
+ return GNUNET_OK;
+}
diff --git a/src/challenger/challenger-httpd_common.h b/src/challenger/challenger-httpd_common.h
@@ -78,6 +78,23 @@ CH_code_to_nonce (const char *code,
/**
+ * The challenge of @a nonce was solved, build the full redirect URL
+ * for the client.
+ *
+ * @param nonce the nonce of the challenge
+ * @param connection connection being handled
+ * @param[out] url set to the redirect URL
+ * @return #GNUNET_OK on success,
+ * #GNUNET_NO if an error was returned on @a connection (#MHD_YES)
+ * #GNUNET_SYSERR to just close @a connection (#MHD_NO)
+ */
+enum GNUNET_GenericReturnValue
+CH_build_full_redirect_url (const struct CHALLENGER_ValidationNonceP *nonce,
+ struct MHD_Connection *connection,
+ char **url);
+
+
+/**
* Send a OAuth 2.0 response indicating an error following
* section 5.2 of RFC 6749.
*
diff --git a/src/challenger/challenger-httpd_solve.c b/src/challenger/challenger-httpd_solve.c
@@ -118,33 +118,6 @@ cleanup_ctx (void *cls)
/**
- * Generate error reply in the format requested by
- * the client.
- *
- * @param bc our context
- * @param template error template to use
- * @param http_status HTTP status to return
- * @param ec error code to return
- * @param hint human-readable hint to give
- */
-static enum MHD_Result
-reply_error (struct SolveContext *bc,
- const char *template,
- unsigned int http_status,
- enum TALER_ErrorCode ec,
- const char *hint)
-{
- struct CH_HandlerContext *hc = bc->hc;
-
- return TALER_MHD_reply_with_error (
- hc->connection,
- http_status,
- ec,
- hint);
-}
-
-
-/**
* Iterator over key-value pairs where the value may be made available
* in increments and/or may not be zero-terminated. Used for
* processing POST data.
@@ -220,11 +193,11 @@ CH_handler_solve (struct CH_HandlerContext *hc,
sizeof (bc->nonce)))
{
GNUNET_break_op (0);
- return reply_error (bc,
- "invalid-request",
- MHD_HTTP_BAD_REQUEST,
- TALER_EC_GENERIC_PARAMETER_MALFORMED,
- "nonce");
+ return TALER_MHD_reply_with_error (
+ hc->connection,
+ MHD_HTTP_BAD_REQUEST,
+ TALER_EC_GENERIC_PARAMETER_MALFORMED,
+ "nonce");
}
TALER_MHD_check_content_length (hc->connection,
1024);
@@ -246,11 +219,11 @@ CH_handler_solve (struct CH_HandlerContext *hc,
if (NULL == bc->pin)
{
GNUNET_break_op (0);
- return reply_error (bc,
- "invalid-request",
- MHD_HTTP_BAD_REQUEST,
- TALER_EC_GENERIC_PARAMETER_MISSING,
- "pin");
+ return TALER_MHD_reply_with_error (
+ hc->connection,
+ MHD_HTTP_BAD_REQUEST,
+ TALER_EC_GENERIC_PARAMETER_MISSING,
+ "pin");
}
{
unsigned int pin;
@@ -266,11 +239,11 @@ CH_handler_solve (struct CH_HandlerContext *hc,
&dummy))
{
GNUNET_break_op (0);
- return reply_error (bc,
- "invalid-request",
- MHD_HTTP_BAD_REQUEST,
- TALER_EC_GENERIC_PARAMETER_MALFORMED,
- "pin");
+ return TALER_MHD_reply_with_error (
+ hc->connection,
+ MHD_HTTP_BAD_REQUEST,
+ TALER_EC_GENERIC_PARAMETER_MALFORMED,
+ "pin");
}
for (unsigned int r = 0; r<MAX_RETRIES; r++)
@@ -290,9 +263,8 @@ CH_handler_solve (struct CH_HandlerContext *hc,
{
case GNUNET_DB_STATUS_HARD_ERROR:
GNUNET_break (0);
- return reply_error (
- bc,
- "internal-error",
+ return TALER_MHD_reply_with_error (
+ hc->connection,
MHD_HTTP_INTERNAL_SERVER_ERROR,
TALER_EC_GENERIC_DB_FETCH_FAILED,
"validate_solve_pin");
@@ -300,16 +272,14 @@ CH_handler_solve (struct CH_HandlerContext *hc,
if (r < MAX_RETRIES - 1)
continue;
GNUNET_break (0);
- return reply_error (
- bc,
- "internal-error",
+ return TALER_MHD_reply_with_error (
+ hc->connection,
MHD_HTTP_INTERNAL_SERVER_ERROR,
TALER_EC_GENERIC_DB_FETCH_FAILED,
"validate_solve_pin");
case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS:
- return reply_error (
- bc,
- "validation-unknown",
+ return TALER_MHD_reply_with_error (
+ hc->connection,
MHD_HTTP_NOT_FOUND,
TALER_EC_CHALLENGER_GENERIC_VALIDATION_UNKNOWN,
NULL);
@@ -329,11 +299,11 @@ CH_handler_solve (struct CH_HandlerContext *hc,
{
GNUNET_log (GNUNET_ERROR_TYPE_INFO,
"Client exhausted all chances to satisfy challenge\n");
- return reply_error (bc,
- "access_denied",
- MHD_HTTP_TOO_MANY_REQUESTS,
- TALER_EC_CHALLENGER_TOO_MANY_ATTEMPTS,
- "users exhausted all possibilities of passing the check");
+ return TALER_MHD_reply_with_error (
+ hc->connection,
+ MHD_HTTP_TOO_MANY_REQUESTS,
+ TALER_EC_CHALLENGER_TOO_MANY_ATTEMPTS,
+ "users exhausted all possibilities of passing the check");
}
GNUNET_log (GNUNET_ERROR_TYPE_INFO,
@@ -365,78 +335,13 @@ CH_handler_solve (struct CH_HandlerContext *hc,
struct MHD_Response *response;
char *url;
unsigned int http_status;
+ enum GNUNET_GenericReturnValue ret;
- {
- char *client_secret;
- json_t *address;
- char *client_scope;
- char *client_state;
- char *client_redirect_uri;
- enum GNUNET_DB_QueryStatus qs;
-
- qs = CHALLENGERDB_validation_get (CH_context,
- &bc->nonce,
- &client_secret,
- &address,
- &client_scope,
- &client_state,
- &client_redirect_uri);
- switch (qs)
- {
- case GNUNET_DB_STATUS_HARD_ERROR:
- GNUNET_break (0);
- return reply_error (bc,
- "internal-server-error",
- MHD_HTTP_INTERNAL_SERVER_ERROR,
- TALER_EC_GENERIC_DB_FETCH_FAILED,
- "validation_get");
- case GNUNET_DB_STATUS_SOFT_ERROR:
- GNUNET_break (0);
- return MHD_NO;
- case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS:
- return reply_error (bc,
- "validation-unknown",
- MHD_HTTP_NOT_FOUND,
- TALER_EC_CHALLENGER_GENERIC_VALIDATION_UNKNOWN,
- NULL);
- case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT:
- break;
- }
- {
- char *code;
- char *url_encoded;
-
- code = CH_compute_code (&bc->nonce,
- client_secret,
- client_scope,
- address,
- client_redirect_uri);
- if (NULL == client_state)
- {
- GNUNET_asprintf (&url,
- "%s?code=%s",
- client_redirect_uri,
- code);
- }
- else
- {
- url_encoded = TALER_urlencode (client_state);
- GNUNET_asprintf (&url,
- "%s?code=%s&state=%s",
- client_redirect_uri,
- code,
- url_encoded);
- GNUNET_free (url_encoded);
- }
- GNUNET_free (code);
- }
- json_decref (address);
- GNUNET_free (client_scope);
- GNUNET_free (client_secret);
- GNUNET_free (client_redirect_uri);
- GNUNET_free (client_state);
- }
-
+ ret = CH_build_full_redirect_url (&bc->nonce,
+ hc->connection,
+ &url);
+ if (GNUNET_OK != ret)
+ return (GNUNET_NO == ret) ? MHD_YES : MHD_NO;
if (0 == CH_get_output_type (hc->connection))
{
{
@@ -487,13 +392,13 @@ CH_handler_solve (struct CH_HandlerContext *hc,
}
{
- enum MHD_Result ret;
+ enum MHD_Result mret;
- ret = MHD_queue_response (hc->connection,
- http_status,
- response);
+ mret = MHD_queue_response (hc->connection,
+ http_status,
+ response);
MHD_destroy_response (response);
- return ret;
+ return mret;
}
}
}
diff --git a/src/challenger/test-challenger-revisit.sh b/src/challenger/test-challenger-revisit.sh
@@ -0,0 +1,229 @@
+#!/bin/bash
+# This file is in the public domain.
+#
+# Tests that re-submitting to /challenge for an already-solved
+# validation returns a redirect_url with both ``code`` and ``state``
+# query parameters, so the user agent can still be sent back to the
+# OAuth client with a usable authorization grant.
+#
+# Regression test: previously the "already solved" branch returned the
+# bare client redirect URI without code/state.
+
+set -eu
+
+function exit_skip() {
+ echo " SKIP: $1"
+ exit 77
+}
+
+function exit_fail() {
+ echo " FAIL: $@"
+ exit 1
+}
+
+function cleanup()
+{
+ for n in $(jobs -p)
+ do
+ kill $n 2> /dev/null || true
+ done
+ rm -f "$LAST_RESPONSE" "$FILENAME"
+ wait
+}
+
+LAST_RESPONSE=$(mktemp responseXXXXXX.log)
+FILENAME="test-challenger-revisit.txt"
+
+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/"
+
+echo -n "Initialize challenger database ..."
+challenger-dbinit -r -c "${CONF}" &> dbinit.log
+echo " OK"
+
+echo -n "Add challenger client ..."
+CLIENT_SECRET="secret-token:secret"
+challenger-admin -c "${CONF}" -a "${CLIENT_SECRET}" "${REDIRECT_URI}" &> admin.log
+echo " OK"
+CLIENT_ID=1
+
+echo -n "Start challenger-httpd ..."
+challenger-httpd -L INFO -c "${CONF}" &> httpd.log &
+
+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 -n "Setup new validation process..."
+STATUS=$(curl "${BURL}/setup/${CLIENT_ID}" \
+ -H "Authorization: Bearer ${CLIENT_SECRET}" \
+ -d '' \
+ -w "%{http_code}" -s -o $LAST_RESPONSE)
+
+if [ "$STATUS" != "200" ]
+then
+ exit_fail "Expected 200 OK. Got: $STATUS" $(cat $LAST_RESPONSE)
+fi
+NONCE=$(jq -r .nonce < "$LAST_RESPONSE")
+echo " OK"
+
+CLIENT_STATE="the-client-state"
+CLIENT_SCOPE="the-client-scope"
+
+echo -n "Initiating user login..."
+STATUS=$(curl "${BURL}/authorize/${NONCE}" \
+ -G \
+ -H "Accept: application/json" \
+ --data-urlencode "response_type=code" \
+ --data-urlencode "client_id=${CLIENT_ID}" \
+ --data-urlencode "redirect_uri=${REDIRECT_URI}" \
+ --data-urlencode "state=${CLIENT_STATE}" \
+ --data-urlencode "scope=${CLIENT_SCOPE}" \
+ -w "%{http_code}" -s -o $LAST_RESPONSE)
+
+if [ "$STATUS" != "200" ]
+then
+ exit_fail "Expected 200 OK. Got: $STATUS" $(cat $LAST_RESPONSE)
+fi
+echo "OK"
+
+
+echo -n "Initiating address submission..."
+STATUS=$(curl "${BURL}/challenge/${NONCE}" \
+ -X POST \
+ -H "Accept: application/json" \
+ --data-urlencode "filename=${FILENAME}" \
+ -w "%{http_code}" -s -o $LAST_RESPONSE)
+
+if [ "$STATUS" != "200" ]
+then
+ exit_fail "Expected 200 OK. Got: $STATUS" $(cat $LAST_RESPONSE)
+fi
+echo "OK"
+
+PIN=$(cat ${FILENAME} | awk '{print $5}')
+
+echo -n "Initiating PIN ${PIN} submission..."
+RESULT=$(curl "${BURL}/solve/${NONCE}" \
+ -X POST \
+ -H "Accept: text/html" \
+ --data-urlencode "pin=${PIN}" \
+ -w "%{http_code} %{redirect_url}" -s -o $LAST_RESPONSE)
+STATUS=$(echo "$RESULT" | awk '{print $1}')
+TARGET=$(echo "$RESULT" | awk '{print $2}')
+
+if [ "$STATUS" != "302" ]
+then
+ exit_fail "Expected 302. Got: $STATUS" $(cat $LAST_RESPONSE)
+fi
+
+# This is where we start to diverge from test-challenger.sh:
+# We track the origional (good) response and then check we get
+# it again later.
+
+ORIG_CODE=$(echo "$TARGET" | sed -e "s/.*?code=//g" -e "s/&.*//g")
+ORIG_STATE=$(echo "$TARGET" | sed -e "s/.*&state=//g")
+
+if [ -z "${ORIG_CODE}" ]
+then
+ exit_fail "No code returned from /solve"
+fi
+echo "OK"
+
+# Now the actual regression test: re-submit the SAME address to
+# /challenge. Since the validation is already solved, the server
+# should reply with a JSON ChallengeRedirect whose redirect_url
+# carries an OAuth ``code`` (and the original ``state``) so the user
+# agent can still be returned to the client with a valid grant.
+echo -n "Re-submitting address after solve..."
+STATUS=$(curl "${BURL}/challenge/${NONCE}" \
+ -X POST \
+ -H "Accept: application/json" \
+ --data-urlencode "filename=${FILENAME}" \
+ -w "%{http_code}" -s -o $LAST_RESPONSE)
+
+if [ "$STATUS" != "200" ]
+then
+ exit_fail "Expected 200 OK on re-submit. Got: $STATUS" $(cat $LAST_RESPONSE)
+fi
+
+TYPE=$(jq -r .type < "$LAST_RESPONSE")
+if [ "$TYPE" != "completed" ]
+then
+ exit_fail "Expected type=completed on re-submit. Got: $TYPE" $(cat $LAST_RESPONSE)
+fi
+
+REDIRECT_URL=$(jq -r .redirect_url < "$LAST_RESPONSE")
+if [ -z "${REDIRECT_URL}" ] || [ "${REDIRECT_URL}" = "null" ]
+then
+ exit_fail "Missing redirect_url in re-submit response" $(cat $LAST_RESPONSE)
+fi
+
+# redirect_url must have a query string with code= and state=.
+case "${REDIRECT_URL}" in
+ *\?*code=*)
+ # Good!
+ ;;
+ *)
+ exit_fail "redirect_url has no 'code' parameter: ${REDIRECT_URL}"
+ ;;
+esac
+case "${REDIRECT_URL}" in
+ *state=*)
+ # Good!
+ ;;
+ *)
+ exit_fail "redirect_url has no 'state' parameter: ${REDIRECT_URL}"
+ ;;
+esac
+
+REVISIT_URL=$(echo "${REDIRECT_URL}" | sed -e "s/?.*//g")
+REVISIT_CODE=$(echo "${REDIRECT_URL}" | sed -e "s/.*?code=//g" -e "s/&.*//g")
+REVISIT_STATE=$(echo "${REDIRECT_URL}" | sed -e "s/.*&state=//g")
+
+if [ "${REVISIT_URL}" != "${REDIRECT_URI}" ]
+then
+ exit_fail "Re-submit redirect URL ${REVISIT_URL} differs from registered ${REDIRECT_URI}"
+fi
+if [ "${REVISIT_STATE}" != "${CLIENT_STATE}" ]
+then
+ exit_fail "Re-submit state ${REVISIT_STATE} differs from original ${CLIENT_STATE}"
+fi
+# The code is deterministic over (nonce, secret, scope, address,
+# redirect_uri), so re-issuing must produce the same value as /solve did.
+if [ "${REVISIT_CODE}" != "${ORIG_CODE}" ]
+then
+ exit_fail "Re-submit code ${REVISIT_CODE} differs from /solve code ${ORIG_CODE}"
+fi
+echo "OK"
+
+exit 0
diff --git a/src/challenger/test-challenger.sh b/src/challenger/test-challenger.sh
@@ -1,5 +1,7 @@
#!/bin/bash
# This file is in the public domain.
+#
+# Tests happy path of challenger mostly.
set -eu