blob: 92c869448946098f64506b56653d3030e9369ee6 [file] [edit]
/* Public domain, no copyright. Use at your own risk. */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "jwt_tests.h"
/* @rfc{7517} Cached remote JWKS source: TTL, Cache-Control/ETag conditional
* refresh (304), kid-miss refresh + cooldown, and the http(s) SSRF guard
* (issue #313). Only meaningful with libcurl; a tiny in-process HTTP server
* serves the JWKS with caching headers and counts requests. */
#ifdef HAVE_LIBCURL
#include <pthread.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
/* A minimal one-key JWKS the test server returns. */
static const char JWKS_BODY[] =
"{\"keys\":[{\"kty\":\"EC\",\"crv\":\"P-256\","
"\"x\":\"Y--DdSpCZ5oF3j__h-SdNJIwvB5aI4AXzpRErGUjWrM\","
"\"y\":\"_bSTCXlDeU-pZZbOKDUVLANspSIeuKZfTM8rtXFG_RU\"}]}";
static struct {
int listen_fd;
int port;
int requests; /* total GETs served */
int conditional; /* GETs that carried If-None-Match */
int max_age; /* Cache-Control: max-age to advertise */
int fail; /* when set, respond 500 */
pthread_t thread;
pthread_mutex_t lock;
volatile int stop;
} srv;
static void *server_thread(void *arg)
{
(void)arg;
for (;;) {
char req[4096];
int fd = accept(srv.listen_fd, NULL, NULL);
ssize_t n;
int cond;
if (fd < 0)
break; /* listen socket closed -> shut down */
n = read(fd, req, sizeof(req) - 1);
if (n <= 0) {
close(fd);
continue;
}
req[n] = '\0';
cond = (strstr(req, "If-None-Match: \"v1\"") != NULL);
pthread_mutex_lock(&srv.lock);
srv.requests++;
if (cond)
srv.conditional++;
pthread_mutex_unlock(&srv.lock);
/* Consume write()'s result (glibc marks it warn_unused_result, and a
* (void) cast does not suppress that under -Werror). We do NOT assert
* it: a test client may legitimately hang up early, and this runs on a
* worker thread where a failing ck_assert would longjmp across threads. */
if (srv.fail) {
const char *e = "HTTP/1.1 500 Internal Server Error\r\n"
"Content-Length: 0\r\n\r\n";
ssize_t w = write(fd, e, strlen(e));
(void)w;
} else if (cond) {
char hdr[256];
int hlen = snprintf(hdr, sizeof(hdr),
"HTTP/1.1 304 Not Modified\r\n"
"ETag: \"v1\"\r\n"
"Cache-Control: max-age=%d\r\n"
"\r\n", srv.max_age);
ssize_t w = write(fd, hdr, hlen);
(void)w;
} else {
char hdr[256];
int hlen = snprintf(hdr, sizeof(hdr),
"HTTP/1.1 200 OK\r\n"
"Content-Type: application/json\r\n"
"ETag: \"v1\"\r\n"
"Cache-Control: max-age=%d\r\n"
"Content-Length: %zu\r\n"
"\r\n", srv.max_age, strlen(JWKS_BODY));
ssize_t wh = write(fd, hdr, hlen);
ssize_t wb = write(fd, JWKS_BODY, strlen(JWKS_BODY));
(void)wh;
(void)wb;
}
close(fd);
}
return NULL;
}
static int server_start(void)
{
struct sockaddr_in addr;
socklen_t alen = sizeof(addr);
memset(&srv, 0, sizeof(srv));
pthread_mutex_init(&srv.lock, NULL);
srv.listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (srv.listen_fd < 0)
return -1;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
addr.sin_port = 0; /* ephemeral */
if (bind(srv.listen_fd, (struct sockaddr *)&addr, sizeof(addr)) ||
listen(srv.listen_fd, 8) ||
getsockname(srv.listen_fd, (struct sockaddr *)&addr, &alen))
return -1;
srv.port = ntohs(addr.sin_port);
return pthread_create(&srv.thread, NULL, server_thread, NULL);
}
static void server_stop(void)
{
shutdown(srv.listen_fd, SHUT_RDWR);
close(srv.listen_fd);
pthread_join(srv.thread, NULL);
pthread_mutex_destroy(&srv.lock);
}
static int req_count(void)
{
int n;
pthread_mutex_lock(&srv.lock);
n = srv.requests;
pthread_mutex_unlock(&srv.lock);
return n;
}
static void req_reset(void)
{
pthread_mutex_lock(&srv.lock);
srv.requests = 0;
srv.conditional = 0;
pthread_mutex_unlock(&srv.lock);
}
static char *make_url(void)
{
char *url = NULL;
ck_assert_int_gt(asprintf(&url, "http://127.0.0.1:%d/jwks", srv.port), 0);
return url;
}
/* A second call within the TTL is served from cache: no new request. */
START_TEST(test_cache_hit)
{
jwk_set_t *set = NULL;
jwks_url_config_t cfg = { .verify = 0, .ttl = 0, .cooldown = 2 };
char *url = make_url();
srv.max_age = 300; /* fresh well past the test duration */
req_reset();
set = jwks_load_fromurl_cached(NULL, url, &cfg);
ck_assert_int_eq(req_count(), 1);
ck_assert_int_gt(jwks_item_count(set), 0);
jwks_load_fromurl_cached(set, url, &cfg);
jwks_load_fromurl_cached(set, url, &cfg);
ck_assert_int_eq(req_count(), 1); /* still cached */
jwks_free(set);
free(url);
}
END_TEST
/* TTL cache hit; stale -> conditional GET (304) keeps the keys. */
START_TEST(test_cache_ttl)
{
jwk_set_t *set = NULL;
jwks_url_config_t cfg = { .verify = 0, .ttl = 1, .cooldown = 2 };
char *url = make_url();
srv.max_age = 1;
req_reset();
/* First call fetches; one request. */
set = jwks_load_fromurl_cached(NULL, url, &cfg);
ck_assert_ptr_nonnull(set);
ck_assert_int_eq(jwks_error(set), 0);
ck_assert_int_gt(jwks_item_count(set), 0);
ck_assert_int_eq(req_count(), 1);
/* After TTL (max-age=1): a conditional GET; the 304 keeps the keys. */
sleep(2);
jwks_load_fromurl_cached(set, url, &cfg);
ck_assert_int_eq(req_count(), 2);
ck_assert_int_eq(srv.conditional, 1);
ck_assert_int_gt(jwks_item_count(set), 0);
ck_assert_int_eq(jwks_error(set), 0);
jwks_free(set);
free(url);
}
END_TEST
/* A kid-miss refresh is bounded by the cooldown. */
START_TEST(test_cooldown)
{
jwk_set_t *set = NULL;
jwks_url_config_t cfg = { .verify = 0, .ttl = 100, .cooldown = 2 };
char *url = make_url();
srv.max_age = 300;
req_reset();
set = jwks_load_fromurl_cached(NULL, url, &cfg);
ck_assert_int_eq(req_count(), 1);
/* Immediately forcing a refresh is within the cooldown: no request. */
jwks_refresh_fromurl(set);
ck_assert_int_eq(req_count(), 1);
/* After the cooldown elapses, a forced refresh does fetch. */
sleep(2);
jwks_refresh_fromurl(set);
ck_assert_int_eq(req_count(), 2);
jwks_free(set);
free(url);
}
END_TEST
/* On an HTTP error during refresh, the previously cached keys are retained
* (a transient 5xx must not wipe a good cache). */
START_TEST(test_refresh_keeps_keys_on_error)
{
jwk_set_t *set = NULL;
jwks_url_config_t cfg = { .verify = 0, .ttl = 100, .cooldown = 0 };
char *url = make_url();
int n;
srv.max_age = 300;
srv.fail = 0;
req_reset();
set = jwks_load_fromurl_cached(NULL, url, &cfg);
n = (int)jwks_item_count(set);
ck_assert_int_gt(n, 0);
/* The origin now errors; a forced refresh must keep the cached keys. */
srv.fail = 1;
jwks_refresh_fromurl(set);
ck_assert_int_ne(jwks_error(set), 0);
ck_assert_int_eq((int)jwks_item_count(set), n);
srv.fail = 0;
jwks_free(set);
free(url);
}
END_TEST
/* Only http(s) is accepted; file:// is rejected (SSRF guard). */
START_TEST(test_scheme_guard)
{
jwk_set_t *set = NULL;
set = jwks_load_fromurl_cached(NULL, "file:///etc/passwd", NULL);
ck_assert_ptr_nonnull(set);
ck_assert_int_ne(jwks_error(set), 0);
ck_assert_int_eq(jwks_item_count(set), 0);
jwks_free(set);
}
END_TEST
#else /* !HAVE_LIBCURL */
START_TEST(test_no_libcurl)
{
ck_assert_ptr_null(jwks_load_fromurl_cached(NULL, "https://x/", NULL));
}
END_TEST
#endif
static Suite *libjwt_suite(const char *title)
{
Suite *s;
TCase *tc_core;
s = suite_create(title);
tc_core = tcase_create("jwt_jwks_cache");
#ifdef HAVE_LIBCURL
tcase_add_test(tc_core, test_cache_hit);
tcase_add_test(tc_core, test_cache_ttl);
tcase_add_test(tc_core, test_cooldown);
tcase_add_test(tc_core, test_refresh_keeps_keys_on_error);
tcase_add_test(tc_core, test_scheme_guard);
#else
tcase_add_test(tc_core, test_no_libcurl);
#endif
tcase_set_timeout(tc_core, 30);
suite_add_tcase(s, tc_core);
return s;
}
int main(void)
{
int failed;
SRunner *sr;
#ifdef HAVE_LIBCURL
if (server_start() != 0) {
fprintf(stderr, "could not start the test HTTP server\n");
return EXIT_FAILURE;
}
#endif
sr = srunner_create(libjwt_suite("LibJWT cached remote JWKS (#313)"));
/* The server thread + request counters live in this process; run the
* tests here too (no fork) so they observe the same state. */
srunner_set_fork_status(sr, CK_NOFORK);
srunner_run_all(sr, CK_VERBOSE);
failed = srunner_ntests_failed(sr);
srunner_free(sr);
#ifdef HAVE_LIBCURL
server_stop();
#endif
return failed ? EXIT_FAILURE : EXIT_SUCCESS;
}